Session Keys
Session keys identify persistent conversations between an app and an agent. Understanding the session key format is essential for building stateful agent interactions.
Session Key Basics
A session key is a unique identifier that groups related events into a conversation. All events with the same session key share conversation history and context.
Example
First event: thread_id = "task-123"
└─ Session key: relay:portal:task-123
└─ Creates new session
Second event (same thread_id, 1 hour later): thread_id = "task-123"
└─ Session key: relay:portal:task-123
└─ Resumes existing session
└─ Agent sees full conversation history
Key Format (In Relay's Database)
In Relay's internal database, session keys include the agent_id:
relay:{agent_id}:{app_id}:{thread_id}
Example:
relay:athena:portal:task-123
relay:klyve:portal:task-123
relay:athena:academy:lesson-101
This format lets Relay track sessions across all agents and apps.
Components
| Component | Example | Notes |
|---|---|---|
| Prefix | relay | Always relay |
| Agent ID | athena | Which agent |
| App ID | portal | Which app |
| Thread ID | task-123 | Within that app |
Key Format (Sent to Agent)
When Relay delivers an event to an agent, the session_key is without the agent_id:
{
"type": "event",
"event_id": "evt_k9p2m",
"session_key": "relay:portal:task-123",
"payload": { ... }
}
Format sent to agent:
relay:{app_id}:{thread_id}
Why the difference?
- Agent already knows its own agent_id from the authentication token
- Sending it would be redundant
- Session key is scoped to one agent per event
Why This Design
Relay's Perspective
Relay stores one full key for bookkeeping:
relay:athena:portal:task-123 ← Relay's internal record
relay:klyve:portal:task-123 ← Different agent, same task!
Both keys exist in the same task. Relay uses the full key to:
- Track which agent handled which session
- Maintain separate conversation histories
- Apply per-agent TTL settings
- Log event processing
Agent's Perspective
An agent only cares about its own sessions:
relay:portal:task-123 ← Agent doesn't need to know it's athena
relay:flow:job-456 ← Just: app and thread
The agent is running in the context of one agent (authenticated), so the agent_id is implicit.
OpenClaw Plugin's Role
The OpenClaw plugin bridges this:
Relay sends to agent:
session_key: "relay:portal:task-123"
Plugin does:
1. Receives session_key
2. Looks up agent_id from auth token: "athena"
3. Creates OpenClaw session: "relay:portal:task-123"
4. Runs agent logic
5. Streams tokens back to Relay
(Plugin doesn't need to prepend athena because Relay already knows
the agent from the connection)
Session Continuity
Sessions persist for a configured TTL (time-to-live). Within that window, Relay maintains full conversation history.
Scenario: Portal Task with Comments
Event 1 (Time: 09:00):
agent_id: athena
thread_id: task-123
message: "@athena what's blocking this?"
→ Creates session: relay:athena:portal:task-123
→ Athena responds
[1 hour passes, user adds another comment]
Event 2 (Time: 10:00):
agent_id: athena
thread_id: task-123
message: "@athena any update?"
→ Same thread_id = Same session key
→ Session key: relay:athena:portal:task-123
→ Athena sees full previous conversation
→ Athena's working memory includes:
- Earlier question
- Athena's earlier response
- New question
Session Expiration
Sessions expire after TTL. Default TTL varies by agent:
Athena: 14 days
Klyve: 30 days
After expiration:
Event 3 (Time: 20 days later):
agent_id: athena
thread_id: task-123
message: "@athena status?"
→ Session: relay:athena:portal:task-123
→ TTL expired (14 days), session deleted
→ Creates NEW session (same key, but empty)
→ Athena doesn't see old conversation
→ App re-injects context via payload if needed
Designing Thread IDs
The thread_id is part of your domain logic. Choose it to reflect your model:
Portal (Task-Based)
thread_id = f"task-{task_id}"
# Example values:
"task-123"
"task-456"
"task-7890"
# Session key format:
relay:athena:portal:task-123
All comments on the same task share one agent session. Good for continuity — agent sees task context evolve.
Academy (Lesson-Based)
thread_id = f"lesson-{lesson_id}"
# Example values:
"lesson-101"
"lesson-201-algebra"
# Session key format:
relay:athena:academy:lesson-101
All student questions about one lesson are one session. Good for learning — agent maintains tutorial context.
Flow (Job-Based)
thread_id = f"job-{job_id}"
# Example values:
"job-abc123"
"job-workflow-456"
# Session key format:
relay:klyve:flow:job-abc123
Each job is one session. Agent maintains job state across steps.
Chat App (User + Channel)
thread_id = f"channel-{channel_id}-{user_id}"
# Example values:
"channel-general-user-789"
"channel-engineering-user-456"
# Session key format:
relay:athena:chat:channel-general-user-789
Each user has separate context per channel. Or just use channel:
thread_id = f"channel-{channel_id}"
# All users in a channel share agent context
relay:athena:chat:channel-general
One-Off Queries
import uuid
thread_id = f"query-{uuid.uuid4()}"
# Example values:
"query-550e8400-e29b-41d4-a716-446655440000"
# Session key format:
relay:athena:app:query-550e8400-e29b-41d4-a716-446655440000
# Each query is a fresh session (no continuity)
Session Key Scope
Session keys are scoped to:
- One agent — Different agents have separate sessions for same thread
- One app — Apps don't share sessions
- One organization — Different organizations don't share sessions
Portal app, Athena agent, organization "ckgworks":
relay:athena:portal:task-123
Portal app, Klyve agent, organization "ckgworks":
relay:klyve:portal:task-123 ← Different session!
Academy app, Athena agent, organization "ckgworks":
relay:athena:academy:lesson-101 ← Different app, different session
Portal app, Athena agent, organization "other":
relay:athena:portal:task-123 ← Different organization, different session
Managing Session Continuity
Re-Injecting Context
When a session expires, the app can inject context in the payload:
# App knows the session likely expired (> TTL), so include full context
payload = {
"message": "@athena what's the status?",
"task_id": "task-123",
"task_title": "Q2 Roadmap Review",
"task_description": "Complete timeline and deliverables",
"comments": [
{
"author": "Christian Garcia",
"text": "Blocking: need design approval",
"timestamp": "2026-04-01T10:00:00Z"
},
{
"author": "Athena",
"text": "Recommend: get design sign-off before proceeding",
"timestamp": "2026-04-01T10:30:00Z"
}
]
}
# Relay doesn't validate or parse this — agent sees full context
# Even if session expired, agent has what it needs
Checking Session Age
In your custom agent server, you can track session creation time:
class SessionManager:
def __init__(self):
self.sessions = {}
def get_session(self, session_key):
if session_key not in self.sessions:
self.sessions[session_key] = {
"created_at": time.time(),
"messages": [],
"context": {}
}
return self.sessions[session_key]
def is_fresh_session(self, session_key):
"""Check if session is new (< 5 minutes)"""
session = self.sessions.get(session_key)
if not session:
return True # Doesn't exist yet
age_seconds = time.time() - session["created_at"]
return age_seconds < 300 # 5 minutes
def is_likely_expired(self, session_key, ttl_days=14):
"""Check if session might have expired"""
session = self.sessions.get(session_key)
if not session:
return True
age_seconds = time.time() - session["created_at"]
ttl_seconds = ttl_days * 24 * 60 * 60
return age_seconds > ttl_seconds
Best Practices
Do
- Use consistent thread_id format per app
- Design thread_ids based on your domain
- Re-inject context after TTL expiry
- Use unique thread_ids for independent conversations
- Document your thread_id scheme
Don't
- Use random/UUID for thread_id (breaks continuity)
- Mix different thread_id formats
- Assume sessions never expire
- Change thread_id format mid-app
- Use empty or null thread_ids
Examples
Portal Task Comments
class PortalIntegration:
async def send_comment_to_agent(self, task_id, comment_text):
thread_id = f"task-{task_id}"
payload = {
"event": "comment.mention",
"message": comment_text,
"task_id": task_id,
"task_title": await get_task_title(task_id),
"project": await get_task_project(task_id)
}
await send_event(ws, "athena", thread_id, payload)
# Session: relay:athena:portal:task-123
Academy Lesson Questions
class AcademyIntegration:
async def send_question_to_agent(self, lesson_id, question_text):
thread_id = f"lesson-{lesson_id}"
payload = {
"event": "question",
"message": question_text,
"lesson_id": lesson_id,
"lesson_title": await get_lesson_title(lesson_id)
}
await send_event(ws, "athena", thread_id, payload)
# Session: relay:athena:academy:lesson-101
Flow Job Workflow
class FlowIntegration:
async def send_job_event(self, job_id, event_type, inputs):
thread_id = f"job-{job_id}"
payload = {
"event": event_type,
"job_id": job_id,
"inputs": inputs,
"context": await get_job_context(job_id)
}
await send_event(ws, "klyve", thread_id, payload)
# Session: relay:klyve:flow:job-abc123
FAQ
Q: Can I change thread_id format mid-app? A: Not recommended. Changing format creates new sessions. Old sessions become orphaned.
Q: What if I accidentally use same thread_id for unrelated conversations? A: Agent sees all messages in that session. You'll get mixed context. Use unique thread_ids.
Q: Can multiple agents share a session? A: No, each agent has its own session per thread_id. This is by design.
Q: How do I know if a session expired? A: You can't query directly. If agent doesn't remember context, session likely expired. Re-inject context in payload.
Q: Is session_key guaranteed unique across time? A: Within an organization, yes. But after expiry, a new session can be created with same key.