Skip to main content

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 IdentityEngineVMRunner Port
reggie_ocOpenClaw192.168.77.1719191
reggie_fdFactory Droid192.168.77.1719091
henry_ocOpenClaw192.168.77.1618181
henry_fdFactory Droid192.168.77.1618081

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

ChannelIdentifier SourceResolution PathExample person_id
admin_chatJWT user.subDirect from auth token42
header_widgetJWT user.subDirect from auth token42
smsPhone numberstaff.mobileparticipants.phone → fallbackstaff:17 or phone:+61412345678
apiAPI key ownerKey lookupapi:key_abc123
webhookPayload metadataExtract from sender contextVaries

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:

  1. Engine context windows eventually expire or get truncated
  2. 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 TypeAuthority LevelWhat the Agent Should Do
user (admin staff)FullCan query any data, run reports, access all tools
staff (field staff)StandardOwn roster, shifts, leave, policies, training
participantLimitedOwn care plan, schedules, support contacts
externalMinimalGeneral 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.

StepFunctionWhat It Does
1resolveType2Identity(channel, identifier)Map the channel's user identifier to a canonical person_id
2getOrCreateSession(person_id, agent_name, channel)Find or create a session row, load summary
3maybeSummarise(session)If stale, merge new messages into summary
4buildType2Prompt(session, message, personMeta)Construct the full prompt with context
5POST to runner webhookSame URLs, same payload format as admin chat
6logType2Message(...) × 2Log 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_oc and reggie_fd point 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:

EngineSession ID FormatExample
OpenClawInternal session stringocs_a1b2c3d4e5
Factory DroidUUID or hashf7e8d9c0-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:

  1. Keep comms.agent_conversations operational for the current admin chat
  2. Build the new brainframe.type2_* tables alongside
  3. Refactor agent-chat.js to use the new tables
  4. 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_context functions 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

PhaseScopeDepends On
Phase 1Create type2_sessions and type2_messages tables. Refactor agent-chat.js to use them. Admin chat works with persistent sessions.Tables only
Phase 2Implement resolveType2Identity() and getOrCreateSession() as shared functions in a new backend/services/type2-session.js module.Phase 1
Phase 3Implement summary generation (time-gated, flash model). Cross-engine summary sharing.Phase 2 + flash model endpoint
Phase 4Wire SMS channel through the same pipeline. Retire old Reggie SMS context logic.Phase 2 + TextMagic handler
Phase 5Embedding + categorisation pipeline. Analytics dashboard.Phase 1 + embedding model
Phase 6Header widget mini-chat, future channels.Phase 2