Document the POST /event/normalized endpoint contract with: - Complete endpoint specification (URL, method, request/response) - All 4 event types with field descriptions and examples - Derivation guide for sessionId, paneId, cwd, transcriptPath from harness context - Implementation example for adapter authors - Error handling and versioning guidance This enables future adapter authors to integrate any coding harness without reading the daemon source code.
9.5 KiB
Normalized Event Contract
Overview
The Trail Boss daemon accepts normalized events via the POST /event/normalized endpoint. This contract is harness-agnostic — any adapter can emit these events to integrate a coding harness with Trail Boss, regardless of the underlying AI provider or session model.
Endpoint
POST /event/normalized
URL: http://127.0.0.1:4000/event/normalized
Content-Type: application/json
Authentication: None (localhost-only daemon)
Request Body: A JSON object representing one of the event types below. All events include a type discriminator field.
Response (Success):
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true
}
Error Responses:
| Status | Body | Description |
|---|---|---|
| 400 | {"error": "Invalid JSON"} |
Request body is not valid JSON |
| 400 | {"error": "Invalid or missing event type"} |
Missing or invalid type field |
| 500 | {"error": "Internal server error"} |
Server-side processing error |
Event Types
All events share a common structure with a type discriminator. The daemon dispatches based on this field.
StuckEvent
Emitted when a session becomes stuck (blocked on user input).
Type Discriminator: "stuck"
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "stuck" |
sessionId |
string | Yes | Unique session identifier (harness-specific) |
paneId |
string | Yes | tmux pane identifier (e.g., %3) |
cwd |
string | Yes | Current working directory of the session |
transcriptPath |
string | Yes | Path to the session transcript file |
reason |
string | Yes | Either "stopped" (hook event) or "permission" (tool blocked) |
message |
string | Yes | Human-readable context: last assistant message (stopped) or tool operation (permission) |
timestamp |
number | Yes | Unix timestamp in milliseconds |
Example (stopped):
{
"type": "stuck",
"sessionId": "sess_01a2b3c4d5e6f7g8",
"paneId": "%12",
"cwd": "/home/coding/project",
"transcriptPath": "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl",
"reason": "stopped",
"message": "I'm waiting for you to review these changes before proceeding.",
"timestamp": 1719987600000
}
Example (permission):
{
"type": "stuck",
"sessionId": "sess_01a2b3c4d5e6f7g8",
"paneId": "%12",
"cwd": "/home/coding/project",
"transcriptPath": "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl",
"reason": "permission",
"message": "[Bash] {\"command\":\"rm -rf /tmp/cache\"}",
"timestamp": 1719987600000
}
UnstuckEvent
Emitted when a session becomes unstuck (user provided input or the session resumed).
Type Discriminator: "unstuck"
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "unstuck" |
sessionId |
string | Yes | Unique session identifier (must match a previously stuck session) |
timestamp |
number | Yes | Unix timestamp in milliseconds |
Example:
{
"type": "unstuck",
"sessionId": "sess_01a2b3c4d5e6f7g8",
"timestamp": 1719987660000
}
Note: The daemon automatically looks up the paneId from the stored session state for cleanup.
SessionRegistered
Emitted when a new session starts. Registers the session metadata with the daemon.
Type Discriminator: "registered"
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "registered" |
sessionId |
string | Yes | Unique session identifier (harness-specific) |
paneId |
string | Yes | tmux pane identifier (e.g., %3) |
cwd |
string | Yes | Current working directory of the session |
transcriptPath |
string | Yes | Path to the session transcript file |
timestamp |
number | Yes | Unix timestamp in milliseconds |
Example:
{
"type": "registered",
"sessionId": "sess_01a2b3c4d5e6f7g8",
"paneId": "%12",
"cwd": "/home/coding/project",
"transcriptPath": "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl",
"timestamp": 1719987600000
}
SessionEnded
Emitted when a session terminates. Cleans up session state from the daemon.
Type Discriminator: "ended"
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "ended" |
sessionId |
string | Yes | Unique session identifier |
timestamp |
number | Yes | Unix timestamp in milliseconds |
Example:
{
"type": "ended",
"sessionId": "sess_01a2b3c4d5e6f7g8",
"timestamp": 1719988000000
}
Deriving Fields from Harness Context
This section explains how to derive the normalized event fields from common harness contexts.
sessionId
The session identifier is harness-specific. Common sources:
| Harness | Session ID Source |
|---|---|
| Claude Code | Transcript filename or internal session UUID |
| Aider | Thread ID or workspace session key |
| Cursor | VS Code workspace URI + session UUID |
| Custom | Generate a UUID or use thread/conversation ID |
Key properties:
- Must be unique per session (across restarts of the same session)
- Should be stable for the lifetime of the session
- Can be a UUID, hash, or harness-provided ID
paneId
The tmux pane identifier in the form %<number>. Derive from:
Method 1: Environment variable (recommended)
# The TMUX_PANE env var is set automatically in tmux sessions
echo $TMUX_PANE # Outputs: %12
Method 2: tmux command
# Get the pane ID of the current tmux session
tmux display -p '#{pane_id}' # Outputs: %12
Method 3: Parsing tmux socket path
# Extract from TMUX env var
tmux show-env | grep TMUX_PANE
cwd
The current working directory of the session.
Method 1: Direct API (if harness provides it)
// Claude Code example
const cwd = session.cwd;
Method 2: Environment variable
# In the session's shell context
pwd
Method 3: Infer from transcript path
# If transcript path is /home/user/.claude/sessions/.../transcript.jsonl
# The session may have been invoked from the project root
transcriptPath
The path to the session transcript file. This is used by the reconcile loop for recovery.
Common patterns:
| Harness | Transcript Path Pattern |
|---|---|
| Claude Code | $HOME/.claude/sessions/<sessionId>/transcript.jsonl |
| Aider | Project directory .aider.transcript.md or .aider.cache/<thread>/transcript |
| Cursor | $HOME/.cursor/sessions/<sessionId>/transcript.jsonl |
| Custom | Adapter-defined location |
Claude Code example:
// From hook event payload
const transcriptPath = hookEvent.transcript_path;
// "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl"
timestamp
Unix timestamp in milliseconds. Use the current time when emitting the event.
const timestamp = Date.now();
Adapter Implementation Example
Here's a minimal adapter skeleton for a hypothetical harness:
interface NormalizedEvent {
type: "stuck" | "unstuck" | "registered" | "ended";
}
async function emitEvent(event: NormalizedEvent): Promise<void> {
const response = await fetch("http://127.0.0.1:4000/event/normalized", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Trail Boss error: ${error.error}`);
}
}
// When session starts
await emitEvent({
type: "registered",
sessionId: getSessionId(),
paneId: process.env.TMUX_PANE,
cwd: process.cwd(),
transcriptPath: getTranscriptPath(),
timestamp: Date.now(),
});
// When session blocks on permission
await emitEvent({
type: "stuck",
sessionId: getSessionId(),
paneId: process.env.TMUX_PANE,
cwd: process.cwd(),
transcriptPath: getTranscriptPath(),
reason: "permission",
message: `[${toolName}] ${JSON.stringify(toolInput)}`,
timestamp: Date.now(),
});
// When user provides input
await emitEvent({
type: "unstuck",
sessionId: getSessionId(),
timestamp: Date.now(),
});
// When session ends
await emitEvent({
type: "ended",
sessionId: getSessionId(),
timestamp: Date.now(),
});
Validation
The daemon validates events at runtime:
- JSON validity: Request body must be parseable JSON
- Type discriminator: The
typefield must be one of:stuck,unstuck,registered,ended - Required fields: Each event type must include its required fields (see table above)
Additional validation (field formats, sessionId existence) may be added in future versions.
Error Handling
Adapters should handle errors gracefully:
| Scenario | Recommended Handling |
|---|---|
| Daemon not running | Log warning, queue event locally, or retry with backoff |
| Invalid event type | Log error, fix adapter code |
| Missing required field | Log error, fix adapter code |
| Network error | Retry with exponential backoff, batch pending events |
Versioning
This contract corresponds to Trail Boss daemon v1.0. Future versions may:
- Add optional fields to existing events (backward compatible)
- Add new event types (backward compatible)
- Introduce a version header for breaking changes
For breaking changes, the daemon will version the endpoint (e.g., /event/normalized/v2).