Skip to main content

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

ComponentExampleNotes
PrefixrelayAlways relay
Agent IDathenaWhich agent
App IDportalWhich app
Thread IDtask-123Within 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:

  1. One agent — Different agents have separate sessions for same thread
  2. One app — Apps don't share sessions
  3. 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.