Sending Your First Event
This guide takes you deeper into the event lifecycle. You'll understand what happens at each stage and see real examples in Python and Node.js.
The Event Journey
When you send an event to Relay, this happens:
1. App sends event
↓
2. Relay validates & accepts
↓
3. Relay routes to agent (or queues if offline)
↓
4. Agent receives event
↓
5. Agent processes and starts sending reply tokens
↓
6. App receives tokens (streaming)
↓
7. Agent sends finish signal
↓
8. App receives complete reply
Let's walk through each step.
Step 1: App Sends Event
Your app sends a JSON message over WebSocket to Relay:
{
"type": "event",
"agent_id": "athena",
"thread_id": "portal:task-123:comment-456",
"payload": {
"event": "comment.mention",
"message": "@athena summarize this discussion",
"sender": {
"id": "user-789",
"name": "Sarah Chen"
},
"task_id": "task-123",
"task_title": "Q2 Roadmap Review",
"project": "Smiling Group Platform",
"comments": [
{
"author": "John",
"text": "I think we should prioritize mobile..."
},
{
"author": "Sarah",
"text": "@athena summarize this discussion"
}
]
}
}
Event Fields
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | Yes | Always "event" |
agent_id | string | Yes | The agent ID (e.g., "athena", "klyve") |
thread_id | string | Yes | Unique ID for this conversation thread. Can include slashes (e.g., "portal:task-123:comment-456") |
payload | object | Yes | App-specific data. Relay doesn't parse this. Max 64KB. |
Payload Field
The payload is sacred. Relay never touches it. Your app defines the shape.
Examples of what different apps might send:
Portal (comment mention):
{
"event": "comment.mention",
"task_id": "task-123",
"comment_id": "cmnt-456",
"task_title": "...",
"comments": [...],
"sender": {...}
}
Flow (task assignment):
{
"event": "task.assign",
"task_id": "task-789",
"task_title": "Design new dashboard",
"description": "...",
"assigned_to": "klyve",
"due_date": "2026-04-15",
"context": {...}
}
Academy (quiz question):
{
"event": "quiz.generate",
"course_id": "course-101",
"module": "Python Basics",
"topic": "Functions",
"difficulty": "intermediate",
"num_questions": 5
}
Relay just passes these through. The agent sees exactly what the app sent.
Step 2: Relay Accepts the Event
Once Relay receives your event, it validates and sends back an acceptance message:
{
"type": "accepted",
"event_id": "evt_k9p2m",
"agent_id": "athena",
"session_key": "relay:athena:portal:task-123",
"status": "accepted"
}
Acceptance Fields
| Field | Type | Notes |
|---|---|---|
type | string | Always "accepted" |
event_id | string | Relay's unique ID for this event. Use this to correlate replies. |
agent_id | string | Echoed back (confirms routing) |
session_key | string | Relay's internal session identifier. See Sessions for details. |
status | string | Always "accepted" in success case |
What it means: Your event made it to Relay, passed validation, and is being routed to the agent (or queued if the agent is offline).
Step 3: Relay Routes to Agent
Relay checks:
- Is the agent online? If yes, send it now. If no, queue it and send when online.
- Is this app allowlisted for this agent? If no, reject.
- Are we rate-limited? If yes, queue with backoff.
If all checks pass, Relay delivers the event to the agent over the agent's WebSocket.
The agent receives:
{
"type": "event",
"event_id": "evt_k9p2m",
"agent_id": "athena",
"session_key": "relay:athena:portal:task-123",
"payload": {
"event": "comment.mention",
"message": "@athena summarize this discussion",
...
}
}
Step 4: Agent Processes the Event
The agent reads the event, understands it, and starts generating a response.
In pseudocode:
# Agent receives event
event = await ws.recv()
# Agent processes
context = event['payload']
response = llm.generate(prompt=context)
# Agent sends back tokens
for token in response.tokens():
send({"type": "token", "event_id": event['event_id'], "token": token})
# Agent signals completion
send({
"type": "reply",
"event_id": event['event_id'],
"content": response.full_text(),
"finish_reason": "stop",
"payload": event['payload'] # Echo back original
})
Step 5: Agent Sends Reply Tokens (Streaming)
As the agent generates its response, it sends one token at a time:
{
"type": "token",
"event_id": "evt_k9p2m",
"token": "This"
}
{
"type": "token",
"event_id": "evt_k9p2m",
"token": " discussion"
}
{
"type": "token",
"event_id": "evt_k9p2m",
"token": " covers"
}
(and so on, token by token)
Why Streaming?
Streaming lets your app show a "typing..." indicator in real-time. Instead of waiting for the full response, the user sees tokens appear as they're generated.
Step 6: App Receives Tokens
Your app receives each token and can:
- Append to a buffer
- Display a "typing..." animation
- Show tokens in real-time to the user
Example Python code:
import asyncio
import websockets
import json
APP_TOKEN = "rlk_portal_x8k2m9p..."
RELAY_URL = "wss://api.relay.ckgworks.com/v1/ws/app"
reply_buffer = {}
async def connect_app():
headers = {"Authorization": f"Bearer {APP_TOKEN}"}
async with websockets.connect(RELAY_URL, extra_headers=headers) as ws:
# Listen for incoming messages
while True:
message = await ws.recv()
data = json.loads(message)
if data["type"] == "token":
event_id = data["event_id"]
token = data["token"]
# Collect tokens by event_id
if event_id not in reply_buffer:
reply_buffer[event_id] = ""
reply_buffer[event_id] += token
# Show progress (e.g., update UI)
print(f"Agent response: {reply_buffer[event_id]}")
asyncio.run(connect_app())
Step 7: Agent Sends Finish Signal
When the agent is done generating, it sends a reply message with the complete response:
{
"type": "reply",
"event_id": "evt_k9p2m",
"content": "This discussion covers Q2 roadmap priorities, including mobile-first design, API improvements, and performance optimization. Key takeaways: 1) Mobile is the top priority. 2) We should deprecate the legacy API by end of Q2. 3) Performance is critical for user retention.",
"finish_reason": "stop",
"payload": {
"event": "comment.mention",
"message": "@athena summarize this discussion",
...
}
}
Reply Fields
| Field | Type | Notes |
|---|---|---|
type | string | Always "reply" |
event_id | string | Matches the original event |
content | string | Complete response text from the agent |
finish_reason | string | "stop" = normal completion, "length" = hit token limit, "error" = agent error |
payload | object | Echoed back — original payload from the event. Use this to route the response. |
Why echo the payload back?
Your app sent the payload to the agent. When the agent replies, the payload comes back. This lets your app know:
- Which task/comment/quiz this reply is for
- Where to display the response
- How to format or process it
Step 8: App Receives Complete Reply
Your app now has:
- Individual tokens (for streaming UI)
- Complete response (for final display/storage)
- Original payload (for routing)
Example code to handle the reply:
if data["type"] == "reply":
event_id = data["event_id"]
content = data["content"]
payload = data["payload"]
finish_reason = data["finish_reason"]
# Route the response
if payload["event"] == "comment.mention":
task_id = payload["task_id"]
# Post response as comment on task_id
portal.post_comment(
task_id=task_id,
author="Athena (AI)",
text=content
)
elif payload["event"] == "quiz.generate":
course_id = payload["course_id"]
# Save generated quiz questions
academy.save_quiz(
course_id=course_id,
questions=content, # or parse as JSON
generated_by="OpenAI via Relay"
)
Full Example: Python App
Here's a complete, runnable example:
import asyncio
import websockets
import json
import uuid
APP_TOKEN = "rlk_portal_x8k2m9p..."
RELAY_URL = "wss://api.relay.ckgworks.com/v1/ws/app"
async def main():
headers = {"Authorization": f"Bearer {APP_TOKEN}"}
async with websockets.connect(
RELAY_URL,
subprotocols=["relay-app"],
extra_headers=headers
) as ws:
print("Connected to Relay\n")
# 1. Send event
event = {
"type": "event",
"agent_id": "athena",
"thread_id": f"portal:task-123:comment-{uuid.uuid4().hex[:8]}",
"payload": {
"event": "comment.mention",
"message": "@athena summarize this discussion",
"task_id": "task-123",
"task_title": "Q2 Roadmap Review",
"comments": [
{"author": "John", "text": "Mobile first..."},
{"author": "Sarah", "text": "@athena summarize this discussion"}
]
}
}
print(f"→ Sending event: {event['type']}\n")
await ws.send(json.dumps(event))
# 2. Receive messages
reply_buffer = {}
while True:
message = await ws.recv()
data = json.loads(message)
msg_type = data.get("type")
if msg_type == "accepted":
print(f"✓ Event accepted")
print(f" Event ID: {data['event_id']}")
print(f" Session: {data['session_key']}\n")
elif msg_type == "token":
event_id = data["event_id"]
token = data["token"]
if event_id not in reply_buffer:
reply_buffer[event_id] = ""
reply_buffer[event_id] += token
print(f"← {reply_buffer[event_id]}", end="", flush=True)
elif msg_type == "reply":
event_id = data["event_id"]
print(f"\n\n✓ Reply complete")
print(f" Content: {data['content'][:100]}...")
print(f" Finish reason: {data['finish_reason']}\n")
break
asyncio.run(main())
Run it:
pip install websockets
python app.py
Output:
Connected to Relay
→ Sending event: event
✓ Event accepted
Event ID: evt_k9p2m
Session: relay:athena:portal:task-123
← This discussion covers Q2 roadmap...← priorities, including mobile-first...← design, API improvements, and...
✓ Reply complete
Content: This discussion covers Q2 roadmap priorities, including mobile-first design, API improvements, and...
Finish reason: stop
Full Example: Node.js Agent
Here's a complete agent that receives events and streams replies:
const WebSocket = require("ws");
const { Readable } = require("stream");
const AGENT_TOKEN = "rla_athena_k9x2p...";
const RELAY_URL = "wss://api.relay.ckgworks.com/v1/ws/agent";
const ws = new WebSocket(RELAY_URL, ["relay-agent"], {
headers: {
Authorization: `Bearer ${AGENT_TOKEN}`,
},
});
ws.on("open", () => {
console.log("Agent connected to Relay\n");
});
ws.on("message", (data) => {
const message = JSON.parse(data);
if (message.type === "event") {
const eventId = message.event_id;
const payload = message.payload;
console.log(`← Event received: ${eventId}`);
console.log(` Task: ${payload.task_title}\n`);
// Simulate agent generating a response
const response = `This discussion covers the main topics: ${payload.comments.length} comments discussing priorities.`;
// 1. Stream tokens (one at a time)
let charIndex = 0;
const tokenInterval = setInterval(() => {
if (charIndex < response.length) {
const token = response.charAt(charIndex);
ws.send(JSON.stringify({
type: "token",
event_id: eventId,
token: token
}));
charIndex++;
} else {
// 2. Send final reply
clearInterval(tokenInterval);
ws.send(JSON.stringify({
type: "reply",
event_id: eventId,
content: response,
finish_reason: "stop",
payload: payload // Echo back original
}));
console.log(`→ Reply sent (${response.length} chars)\n`);
}
}, 50); // 50ms between tokens
}
});
ws.on("close", () => {
console.log("Connection closed");
});
ws.on("error", (err) => {
console.error("Error:", err);
});
Run it:
npm install ws
node agent.js
Error Cases
What if the agent is offline?
Relay queues the event and tries to deliver it when the agent reconnects (with TTL limits).
Your app receives accepted immediately, but the agent won't receive the event until it comes back online.
What if the app isn't allowlisted?
Relay sends back an error message:
{
"type": "error",
"code": "ALLOWLIST_DENIED",
"message": "App is not allowlisted for this agent"
}
What if the agent hits an error?
The agent sends back a reply with finish_reason: "error":
{
"type": "reply",
"event_id": "evt_k9p2m",
"content": null,
"finish_reason": "error",
"error": "Rate limited by OpenAI API",
"payload": {...}
}
Your app should check finish_reason and handle errors gracefully.
Best Practices
-
Collect tokens by event_id. Multiple events might be in flight. Use event_id to match tokens to their event.
-
Echo the payload. Always echo the original payload back in your reply. This lets the app route responses.
-
Set reasonable thread_ids. Use a format that makes sense for your app (e.g.,
portal:task-123:comment-456). This helps with debugging. -
Handle finish_reason. Check for
"error"and"length"in addition to"stop". -
Implement exponential backoff. If connection drops, reconnect with backoff (2s, 4s, 8s, etc.).
-
Log events. Store event_id, timestamp, sender, and agent_id for audit trails.
Next Steps
- Learn about Sessions for conversation continuity
- Explore Payload Design best practices
- Check the WebSocket Messages reference for all message types