Type2 Agent Session Architecture
Status: Implemented
Version: 1.1
Last Updated: 2026-04-18
See also: Type 2 Wiring & Operations Manual for live operational details
1. Executive Summary
Type 2 agents are autonomous conversational agents running on dedicated VMs, accessed via CLI (OpenClaw agent and Factory Droid exec). Unlike the V3/V4 Reggie architecture which is prompt-engineered within RABS, Type 2 agents are external runtimes with their own context windows, tool access, and reasoning capabilities.
This document defines how RABS manages sessions, identity, persistence, and memory for Type 2 agents across all transport channels. The core principle: a conversation belongs to the person + agent identity pair, not the interface or transport.
| Agent Identity | Engine | VM | Runner Port |
|---|---|---|---|
reggie_oc | OpenClaw | 192.168.77.17 | 19191 |
reggie_fd | Factory Droid | 192.168.77.17 | 19091 |
henry_oc | OpenClaw | 192.168.77.16 | 18181 |
henry_fd | Factory Droid | 192.168.77.16 | 18081 |
2. Architecture Overview
┌─────────────────────────────────────────────┐
│ TRANSPORT LAYER │
│ │
│ Admin Chat SMS Header Widget API │
└──────┬────────┬────────┬────────────┬──────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────┐
│ IDENTITY RESOLUTION │
│ │
│ resolveType2Identity(channel, identifier) │
│ → { person_type, person_id, metadata } │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ SESSION MANAGER │
│ │
│ getOrCreateSession(person_id, agent_name) │
│ buildType2Prompt(session, message, meta) │
│ logType2Message(...) │
└─────────────────────┬───────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Runner .16 │ │ Runner .16 │ │ Runner .17 │ ...
│ henry_oc │ │ henry_fd │ │ reggie_oc │
│ (openclaw) │ │ (droid) │ │ (openclaw) │
└────────────┘ └────────────┘ └────────────┘
3. Identity Resolution
Every inbound message, regardless of channel, must resolve to a canonical person_id before reaching a Type 2 agent. This is the single most important function in the architecture — it ensures that a staff member texting from their phone and chatting from the admin dashboard is the same person to the agent.
3.1 Resolution Function
async function resolveType2Identity(channel, channelIdentifier, pool) {
switch (channel) {
case 'admin_chat':
case 'header_widget':
// JWT already decoded by auth middleware
return {
person_type: 'user',
person_id: channelIdentifier.sub, // from JWT
metadata: {
name: `${channelIdentifier.first_name || ''} ${channelIdentifier.last_name || ''}`.trim(),
role: channelIdentifier.role,
email: channelIdentifier.email
}
};
case 'sms':
// Phone number → lookup staff or participant
const staff = await pool.query(
`SELECT id, first_name, last_name, role FROM staff WHERE mobile = $1 AND status = true`,
[channelIdentifier]
);
if (staff.rows.length) {
const s = staff.rows[0];
return {
person_type: 'staff',
person_id: `staff:${s.id}`,
metadata: { name: `${s.first_name} ${s.last_name}`, role: s.role }
};
}
// Fallback: participant lookup
const participant = await pool.query(
`SELECT id, first_name, last_name FROM participants WHERE phone = $1`,
[channelIdentifier]
);
if (participant.rows.length) {
const p = participant.rows[0];
return {
person_type: 'participant',
person_id: `participant:${p.id}`,
metadata: { name: `${p.first_name} ${p.last_name}` }
};
}
// Unknown number
return {
person_type: 'external',
person_id: `phone:${channelIdentifier}`,
metadata: { phone: channelIdentifier }
};
case 'api':
return {
person_type: 'system',
person_id: `api:${channelIdentifier}`,
metadata: {}
};
default:
throw new Error(`Unknown channel: ${channel}`);
}
}
3.2 Resolution Matrix
| Channel | Identifier Source | Resolution Path | Example person_id |
|---|---|---|---|
admin_chat | JWT user.sub | Direct from auth token | 42 |
header_widget | JWT user.sub | Direct from auth token | 42 |
sms | Phone number | staff.mobile → participants.phone → fallback | staff:17 or phone:+61412345678 |
api | API key owner | Key lookup | api:key_abc123 |
webhook | Payload metadata | Extract from sender context | Varies |
4. Database Schema
4.1 Session Registry
One row per person per agent variant. The agent_identity column groups engine types (reggie_oc + reggie_fd = "reggie") for shared summaries.
CREATE TABLE brainframe.type2_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_type TEXT NOT NULL,
person_id TEXT NOT NULL,
agent_identity TEXT NOT NULL, -- 'reggie' or 'henry'
agent_name TEXT NOT NULL, -- 'reggie_oc', 'reggie_fd', etc.
engine_type TEXT NOT NULL, -- 'openclaw' or 'droid'
engine_session_id TEXT, -- session ID returned by the engine
channel TEXT NOT NULL, -- first channel used to create this session
summary TEXT, -- rolling conversation summary
summary_updated_at TIMESTAMPTZ,
last_message_in_summary UUID, -- FK to last message included in summary
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(person_id, agent_name)
);
CREATE INDEX idx_t2s_person ON brainframe.type2_sessions(person_id);
CREATE INDEX idx_t2s_agent ON brainframe.type2_sessions(agent_name);
CREATE INDEX idx_t2s_identity ON brainframe.type2_sessions(person_id, agent_identity);
4.2 Message Log
Every inbound and outbound message across all channels and all agent types.
CREATE TABLE brainframe.type2_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES brainframe.type2_sessions(id),
person_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
agent_identity TEXT NOT NULL,
direction TEXT NOT NULL, -- 'inbound' or 'outbound'
channel TEXT NOT NULL,
message TEXT NOT NULL,
processing_ms INT,
tool_calls_count INT DEFAULT 0,
correlation_id UUID,
metadata JSONB DEFAULT '{}',
embedding VECTOR(1536), -- for semantic search
category TEXT, -- flash model categorisation
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_t2m_session ON brainframe.type2_messages(session_id, created_at);
CREATE INDEX idx_t2m_person ON brainframe.type2_messages(person_id, agent_identity, created_at);
CREATE INDEX idx_t2m_correlation ON brainframe.type2_messages(correlation_id);
CREATE INDEX idx_t2m_category ON brainframe.type2_messages(category) WHERE category IS NOT NULL;
5. Session Lifecycle
5.1 Starting a New Conversation
User sends message via any channel
→ resolveType2Identity() → person_id
→ SELECT FROM type2_sessions WHERE person_id = $1 AND agent_name = $2
→ No row? INSERT new session (engine_session_id = NULL)
→ Build prompt: core memory + summary + user identity + message
→ POST to runner webhook
→ Runner spawns engine CLI (no --session-id on first call)
→ Engine returns response + new session ID
→ UPDATE type2_sessions SET engine_session_id = $1
→ INSERT type2_messages (inbound + outbound)
→ Deliver response via original channel
5.2 Continuing a Conversation
User sends follow-up message
→ resolveType2Identity() → same person_id
→ SELECT FROM type2_sessions → has engine_session_id
→ Build prompt: core memory + summary + user identity + message
→ POST to runner with session_id in payload
→ Runner passes --session-id to engine CLI
→ Engine resumes from its own context window
→ INSERT type2_messages (inbound + outbound)
→ Deliver response
5.3 Switching Engine Types (Same Persona)
A user chatting with reggie_oc then opening reggie_fd:
User sends message to reggie_fd
→ resolveType2Identity() → same person_id
→ SELECT FROM type2_sessions WHERE person_id AND agent_name = 'reggie_fd'
→ No row (first time with FD variant)
→ But: SELECT summary FROM type2_sessions WHERE person_id AND agent_identity = 'reggie'
→ Found summary from reggie_oc sessions!
→ Create new reggie_fd session, pre-populate summary from reggie_oc
→ Engine starts fresh but prompt includes full conversation summary
→ Continuity preserved across engine switch
6. Summary Generation
Summaries are the bridge between engine context windows and long-term memory. They solve two problems:
- Engine context windows eventually expire or get truncated
- Switching engine types loses the engine's internal context
6.1 Time-Gated Trigger
At the start of each new message, check:
const SUMMARY_STALE_MS = 60 * 60 * 1000; // 1 hour
async function maybeSummarise(session, pool) {
if (!session.summary_updated_at) return; // no summary yet, nothing to merge
const lastMsg = await pool.query(
`SELECT created_at FROM brainframe.type2_messages
WHERE session_id = $1 ORDER BY created_at DESC LIMIT 1`,
[session.id]
);
if (!lastMsg.rows.length) return;
const gap = Date.now() - new Date(lastMsg.rows[0].created_at).getTime();
if (gap < SUMMARY_STALE_MS) return; // conversation is still active, skip
// Fetch unsummarised messages across ALL sessions for this person + identity
const newMsgs = await pool.query(
`SELECT direction, message, agent_name, channel, created_at
FROM brainframe.type2_messages
WHERE person_id = $1 AND agent_identity = $2
AND ($3::uuid IS NULL OR id > $3)
ORDER BY created_at`,
[session.person_id, session.agent_identity, session.last_message_in_summary]
);
if (!newMsgs.rows.length) return;
const transcript = newMsgs.rows.map(m =>
`[${m.direction}/${m.agent_name}/${m.channel}] ${m.message}`
).join('\n');
const prompt = `Merge the existing summary with these new messages into a concise updated summary.
Existing summary:
${session.summary || '(none)'}
New messages:
${transcript}
Updated summary:`;
const newSummary = await callFlashModel(prompt);
const lastId = newMsgs.rows[newMsgs.rows.length - 1].id; // not actually returned above, would need id in SELECT
// Update ALL sessions for this person + identity (both engine types share the summary)
await pool.query(
`UPDATE brainframe.type2_sessions
SET summary = $1, summary_updated_at = NOW(), last_message_in_summary = $2, updated_at = NOW()
WHERE person_id = $3 AND agent_identity = $4`,
[newSummary, lastId, session.person_id, session.agent_identity]
);
}
6.2 Summary Scope
Summaries are grouped by agent identity (reggie or henry), not by engine type:
| Messages from... | Feed into summary for... |
|---|---|
reggie_oc (admin chat) | Reggie summary |
reggie_fd (admin chat) | Reggie summary |
reggie_oc (SMS) | Reggie summary |
henry_oc (admin chat) | Henry summary |
henry_fd (admin chat) | Henry summary |
This means a user's complete history with "Reggie" is captured in one summary regardless of which engine or channel was used.
7. Prompt Construction
Every message sent to a Type 2 agent is wrapped with context. The runner already reads a core memory file; the session layer adds identity and history.
function buildType2Prompt(session, message, personMeta) {
const parts = [];
// Core memory is loaded by the runner from coreMEM-{identity}.md
// We add the session-level context here
if (session.summary) {
parts.push(`[Conversation Summary (updated ${session.summary_updated_at?.toISOString()})]\n${session.summary}`);
}
parts.push(`[Current User: ${personMeta.name || 'Unknown'} | Type: ${session.person_type} | Role: ${personMeta.role || 'n/a'}]`);
parts.push(`[Channel: ${session.channel}]`);
parts.push(`[Message]\n${message}`);
return parts.join('\n\n');
}
The full context stack the agent sees:
1. Engine system prompt (configured in OpenClaw gateway / Droid config)
2. Core memory file (coreMEM-reggie.md or coreMEM-henry.md) — loaded by runner
3. Conversation summary — injected by session manager
4. User identity + role + channel — injected by session manager
5. User message
6. Engine's own context window (recent turns from --session-id)
8. Unified Logging Function
All Type 2 interactions use a single logging function. Any new transport or interface calls this same function — no new tables, no new patterns.
async function logType2Message({
session_id, person_id, agent_name, agent_identity,
direction, channel, message,
processing_ms, tool_calls_count, correlation_id, metadata
}, pool) {
const result = await pool.query(`
INSERT INTO brainframe.type2_messages
(session_id, person_id, agent_name, agent_identity, direction, channel,
message, processing_ms, tool_calls_count, correlation_id, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id
`, [
session_id, person_id, agent_name, agent_identity, direction, channel,
message, processing_ms || null, tool_calls_count || 0, correlation_id || null,
JSON.stringify(metadata || {})
]);
return result.rows[0].id;
}
This mirrors the existing emitLog pattern used by unified logging elsewhere in RABS. Every message in, every message out, same function, same table.
9. Access & Authority Model
The session metadata and identity resolution determine what scope of information an agent should provide. This is communicated to the agent via the prompt, not enforced programmatically (the agents themselves are trusted runtimes with their own tool access).
| Person Type | Authority Level | What the Agent Should Do |
|---|---|---|
user (admin staff) | Full | Can query any data, run reports, access all tools |
staff (field staff) | Standard | Own roster, shifts, leave, policies, training |
participant | Limited | Own care plan, schedules, support contacts |
external | Minimal | General information, direct to appropriate contact |
Authority is passed as metadata in the prompt:
[Current User: Jane Smith | Type: staff | Role: support_worker]
The agent's system prompt (in OpenClaw config or Droid instructions) includes directives on how to handle different authority levels.
10. Embedding & Categorisation Pipeline
For messages where semantic search or analytics are desired, an async post-processing job runs after each message pair is stored.
10.1 Embedding Generation
async function embedType2Message(messageId, pool) {
const msg = await pool.query(
`SELECT id, message FROM brainframe.type2_messages WHERE id = $1`, [messageId]
);
if (!msg.rows.length) return;
const embedding = await generateEmbedding(msg.rows[0].message); // uses configured embedding model
await pool.query(
`UPDATE brainframe.type2_messages SET embedding = $1 WHERE id = $2`,
[JSON.stringify(embedding), messageId]
);
}
10.2 Categorisation
A fast/flash model labels each outbound+inbound pair for analytics:
async function categoriseType2Exchange(inboundId, outboundId, pool) {
const msgs = await pool.query(
`SELECT direction, message FROM brainframe.type2_messages WHERE id = ANY($1) ORDER BY created_at`,
[[inboundId, outboundId]]
);
const transcript = msgs.rows.map(m => `${m.direction}: ${m.message}`).join('\n');
const category = await callFlashModel(
`Categorise this exchange into one label: roster_query, policy_question, hr_request, ` +
`participant_care, incident_report, casual_chat, escalation, technical_support, other.\n\n${transcript}`
);
await pool.query(
`UPDATE brainframe.type2_messages SET category = $1 WHERE id = ANY($2)`,
[category.trim().toLowerCase(), [inboundId, outboundId]]
);
}
10.3 What This Enables
- Semantic search across all conversations with any agent, any channel
- Analytics dashboards showing conversation categories, volumes, trends
- Trend detection for recurring issues (e.g., spike in "roster_query" during holiday periods)
- Context injection for future prompts ("You've asked about rosters 3 times this week")
11. Transport Wiring Checklist
To add a new channel that talks to Type 2 agents, implement these six steps. No new database tables, no new runner code, no new agent configuration required.
| Step | Function | What It Does |
|---|---|---|
| 1 | resolveType2Identity(channel, identifier) | Map the channel's user identifier to a canonical person_id |
| 2 | getOrCreateSession(person_id, agent_name, channel) | Find or create a session row, load summary |
| 3 | maybeSummarise(session) | If stale, merge new messages into summary |
| 4 | buildType2Prompt(session, message, personMeta) | Construct the full prompt with context |
| 5 | POST to runner webhook | Same URLs, same payload format as admin chat |
| 6 | logType2Message(...) × 2 | Log inbound message, then outbound response |
Example: Wiring SMS
// In the SMS inbound handler (e.g., TextMagic webhook)
async function handleInboundSMS(from, body, pool, io) {
const identity = await resolveType2Identity('sms', from, pool);
const agentName = 'reggie_oc'; // default agent for SMS
const session = await getOrCreateSession(identity.person_id, agentName, 'sms', pool);
await maybeSummarise(session, pool);
const prompt = buildType2Prompt(session, body, identity.metadata);
const inboundId = await logType2Message({
session_id: session.id, person_id: identity.person_id,
agent_name: agentName, agent_identity: 'reggie',
direction: 'inbound', channel: 'sms', message: body
}, pool);
const response = await postToRunner(agentName, prompt, session.engine_session_id);
if (response.session_id && !session.engine_session_id) {
await pool.query(
`UPDATE brainframe.type2_sessions SET engine_session_id = $1 WHERE id = $2`,
[response.session_id, session.id]
);
}
await logType2Message({
session_id: session.id, person_id: identity.person_id,
agent_name: agentName, agent_identity: 'reggie',
direction: 'outbound', channel: 'sms', message: response.text,
processing_ms: response.processing_ms, tool_calls_count: response.tool_calls_count
}, pool);
// Send SMS reply via TextMagic
await sendSMS(from, response.text);
}
12. Session Filesystem Workspace
Each session gets a folder on the shared storage, giving all agent types a place to store and retrieve files for that person.
12.1 Folder Structure
/mnt/storage_agents/users/sessions/
{session-uuid}/
summary.md -- latest rolling summary
attachments/ -- images, documents, audio, video received or generated
exports/ -- reports, CSVs, generated documents
context/ -- agent working files, scratch notes
The session-uuid is our canonical UUID from brainframe.type2_sessions.id, not the engine session ID. This is because engine session ID formats differ between OpenClaw and Factory Droid. By using our own UUID:
- The folder name is consistent and predictable
- Both engine types for the same person can find the same folder
- The engine-specific session IDs are stored in the DB row for runtime use
12.2 Sync
The folder /mnt/storage_agents/users/sessions/ is two-way synced to {repo_root}/type2Agents/sessions/ via automatic file-change sync. This means:
- Agents on either VM can read/write to the session workspace
- RABS backend can access session files for serving to the frontend
- Files are available locally for development and testing
12.3 Cross-Engine Access
When a person has sessions with both reggie_oc and reggie_fd, each session row has its own workspace_path. However, the get_or_create_type2_session() function can optionally share a workspace path across siblings of the same identity, since the summary is already shared. This is a configuration choice:
- Shared workspace (recommended): Both
reggie_ocandreggie_fdpoint to the same folder. Any files the OC agent saves are visible to the FD agent. - Separate workspaces: Each engine variant has its own folder. Files are isolated but summaries are still shared via the DB.
12.4 Engine Session ID Mapping
Both engines use different session ID formats:
| Engine | Session ID Format | Example |
|---|---|---|
| OpenClaw | Internal session string | ocs_a1b2c3d4e5 |
| Factory Droid | UUID or hash | f7e8d9c0-b1a2-... |
These are stored in type2_sessions.engine_session_id and passed to the CLI via --session-id. The filesystem folder uses our own UUID, not the engine ID.
13. Relationship to Existing Systems
12.1 Current comms.agent_conversations Table
The existing table used by the admin chat page (comms.agent_conversations) will be superseded by brainframe.type2_messages. During migration:
- Keep
comms.agent_conversationsoperational for the current admin chat - Build the new
brainframe.type2_*tables alongside - Refactor
agent-chat.jsto use the new tables - Migrate historical data if needed
12.2 Reggie V3/V4 SMS Architecture
The current Reggie SMS service (docs 18, 24, 25) uses a custom context-gathering pipeline before calling an LLM. With Type 2 agents:
- The context-gathering logic moves into the agent's own tool access (the agent queries RABS APIs itself)
- The SMS handler simplifies to: resolve identity → get session → send to runner → log → reply
- The existing
reggie_contextfunctions become tools available to the Type 2 agent
12.3 Brainframe Integration
The brainframe schema already hosts agent runtime status, cognitive architecture tables, and embedding stores. Type 2 sessions and messages live in the same schema, making joins and analytics straightforward.
13. Implementation Priority
| Phase | Scope | Depends On |
|---|---|---|
| Phase 1 | Create type2_sessions and type2_messages tables. Refactor agent-chat.js to use them. Admin chat works with persistent sessions. | Tables only |
| Phase 2 | Implement resolveType2Identity() and getOrCreateSession() as shared functions in a new backend/services/type2-session.js module. | Phase 1 |
| Phase 3 | Implement summary generation (time-gated, flash model). Cross-engine summary sharing. | Phase 2 + flash model endpoint |
| Phase 4 | Wire SMS channel through the same pipeline. Retire old Reggie SMS context logic. | Phase 2 + TextMagic handler |
| Phase 5 | Embedding + categorisation pipeline. Analytics dashboard. | Phase 1 + embedding model |
| Phase 6 | Header widget mini-chat, future channels. | Phase 2 |