Event Protocol: v0.2.0 · Last Updated: July 19, 2026
This guide walks you through setting up the AEP reference implementation and integrating event emission into your existing AI agents. By the end, your agents will emit structured, traceable events that you can observe in real time through the AEP dashboard.
- What is AEP?
- Prerequisites
- Installation & Server Setup
- AEP Envelope Schema
- Core Event Types
- Integration Steps
- Emitting Events (SDKs & Helper)
- Chaining Events with causation_id
- Mapping Agent Actions to Event Types
- Multi-Agent Systems
- Full Integration Example
- Observing Events in the Dashboard
- CLI Reference
- API Reference
- Docker Deployment
- Production Considerations
The Agent Event Protocol (AEP) is a lightweight, domain-agnostic standard for capturing and observing AI agent activity. It defines a common envelope format and a small vocabulary of core event types that describe the lifecycle of any agent's work: tasks being created and completed, tools being called, memory being accessed, handoffs between agents, and policy enforcement.
AEP is purely an observability layer — it doesn't run your agents or change their behaviour. You emit events at key moments; AEP captures, validates, and visualises them.
AEP uses an at-least-once delivery model. Every event has a unique id, and the ingest server deduplicates by that ID atomically, so agents can safely retry without creating duplicate records. Events within a single agent run share a session_id and trace_id, making it easy to reconstruct the full chain of actions after the fact.
- Node.js 20 or later (tested with Node 20 and 22)
- npm (comes with Node.js)
- A running agent (any language) that can make HTTP POST requests
- A terminal for running the ingest server
From the project root:
npm installADMIN_TOKEN=change-me npm run ingestYou should see:
AEP ingest listening on http://localhost:8787
What "dev mode" does and doesn't open. When DASHBOARD_TOKEN is unset, the
dashboard and read endpoints (/sessions, /metrics, /workflows, /stream)
are open for local convenience. Ingest is different: POST /events always
requires a write-scoped API key — there is no keyless bypass. So you need
ADMIN_TOKEN set (to mint a key) before you can emit events, even in dev.
For a network-accessible deployment, also set DASHBOARD_TOKEN to lock down the
dashboard and read APIs:
ADMIN_TOKEN=change-me DASHBOARD_TOKEN=change-me npm run ingestEmitting events requires a write-scoped key. With ADMIN_TOKEN set, mint one:
curl -s -X POST http://localhost:8787/admin/keys \
-H "Authorization: Bearer change-me" \
-H "Content-Type: application/json" \
-d '{"tenantId":"my-org","label":"dev key","scopes":["read","write"]}'The response includes the raw key — shown once only. Export it so the example emitter and SDKs pick it up automatically:
export AEP_API_KEY=aep_<your-key>Then every ingest request sends Authorization: Bearer $AEP_API_KEY. The
bundled emitter (npm run emit:example) and the demo scripts read AEP_API_KEY
from the environment; a 401 means it is unset or invalid.
Full auth reference: See AUTH.md for API key scopes, HMAC signing setup, tenant isolation, and dashboard token configuration.
curl http://localhost:8787/healthExpected response:
{ "ok": true, "service": "aep-ingest", "version": "1.0.0", "checks": { "db": "ok" } }Note: The
versionfield reflects the server build, not the AEPspecversion. The server defaults to port8787. Set thePORTenvironment variable to change it (e.g.,PORT=9000 npm run ingest).
Every AEP event is a JSON object that conforms to the envelope schema. The envelope ensures a consistent structure so that any consumer (dashboards, analytics, alerting) can process events from any agent without custom parsing.
| Field | Type | Description |
|---|---|---|
specversion |
string | Protocol version. Must be "0.2.0" |
id |
string | Unique event identifier (UUID recommended) |
time |
string | ISO 8601 timestamp of when the event occurred |
source |
string | Identifier for the emitting agent (e.g., "agent://my-agent") |
type |
string | One of the 12 core event types (see Section 5) |
session_id |
string | Groups all events from one agent run or conversation |
trace_id |
string | Groups events in one logical workflow or trace |
payload |
object | Event-specific data (free-form JSON object) |
| Field | Type | Description |
|---|---|---|
causation_id |
string | ID of the event that directly caused this one |
subject |
string | Subject or topic of the event |
idempotency_key |
string | Client-provided key for deduplication |
schema |
string | Payload schema identifier (e.g., "aep.tool.called/1") |
content_type |
string | Payload media type (default: "application/json") |
signature |
object | Cryptographic signature metadata |
tenant |
string | Multi-tenant namespace identifier |
labels |
object | Key-value string tags for filtering |
extensions |
object | Arbitrary extension data |
parent_session_id |
string | session_id of the parent agent that spawned this session. Omit for root/standalone sessions. (Added in v0.2.0) |
agent_role |
enum | Role of the agent: "orchestrator", "subagent", or "standalone". (Added in v0.2.0) |
AEP v0.2.0 defines exactly 12 core event types. The ingest server rejects any event whose type is not in this list. This constraint ensures a consistent, queryable vocabulary across all agents.
| Event Type | Category | When to Use |
|---|---|---|
task.created |
Task Lifecycle | Agent receives a new task or user request |
task.updated |
Task Lifecycle | Task state changes (e.g., progress update, priority change) |
task.completed |
Task Lifecycle | Agent finishes a task successfully |
task.failed |
Task Lifecycle | Agent fails to complete a task |
tool.called |
Tool Usage | Agent invokes an external tool or API |
tool.result |
Tool Usage | Agent receives a response from a tool |
memory.read |
Memory | Agent reads from a knowledge base or context store |
memory.write |
Memory | Agent writes to a knowledge base or context store |
handoff.started |
Handoff | Agent begins transferring control to another agent |
handoff.completed |
Handoff | Handoff is acknowledged or completed |
policy.blocked |
Governance | A policy or guardrail prevents an action |
error.raised |
Error | An unexpected error occurs during processing |
Integrating AEP into an existing agent requires three things: generating session identifiers at startup, emitting events at key decision points, and chaining events together with causation_id.
At the start of every agent run (or conversation), generate a session_id and trace_id. All events in this run share these two values, which is what ties them together in the dashboard.
const sessionId = `ses_${crypto.randomUUID()}`;
const traceId = `trc_${crypto.randomUUID()}`;Note: Any string format works. The demos use a prefix + UUID pattern for readability, but the format is not validated.
Wherever your agent performs a meaningful action — receives a task, calls a tool, reads memory — construct an event and emit it to the ingest server. Section 7 covers the official SDKs (the recommended path) and a raw-HTTP helper for languages without an SDK.
When one event directly causes the next, pass the first event's id as the causation_id of the second. This creates a causal chain that the dashboard renders as a connected timeline and DAG.
The recommended way to emit events is one of the official AEP SDKs, which build the envelope, validate it, sign it, handle retries and rate-limit back-off, and talk to the ingest server for you — so you don't hand-roll the envelope or HTTP. For languages without an SDK, a minimal raw-HTTP helper is shown at the end as a fallback.
| Language | Package | Reference |
|---|---|---|
| Python | pip install -e "sdks/python[dev]" |
sdks/python/README.md |
| Go | go get github.com/surpradhan/agent-event-protocol/sdks/go@latest |
sdks/go/README.md |
The SDK READMEs are the canonical reference for each client — this guide cross-links to them rather than restating their APIs.
from aep import create_event, AEPClient
# Build a spec-compliant event (id + time are auto-generated)
event = create_event(
source="agent://my-agent",
type="task.created",
session_id=session_id,
trace_id=trace_id,
payload={"task": "summarise document"},
causation_id=prior_event_id, # optional: chains to a prior event
)
# Emit to the ingest server
with AEPClient(server_url="http://localhost:8787", api_key="aep_...") as client:
result = client.emit(event)
# {"accepted": True, "duplicate": False, "id": "evt_..."}AEPClient reads AEP_INGEST_URL and AEP_API_KEY from the environment automatically. The package also provides AsyncAEPClient (async + emit_batch), validate_event(), and sign_event() / verify_signature(). See the Python SDK README for the async client, batch emit, HMAC signing, and multi-agent helpers.
import "github.com/surpradhan/agent-event-protocol/sdks/go/aep"
event, _ := aep.CreateEvent(
"agent://my-agent",
aep.EventTypeTaskCreated,
sessionID, traceID,
map[string]interface{}{"task": "analyze document"},
nil, // optional overrides (causation_id, agent_role, etc.)
)
client := aep.NewClient()
client.SetAPIKey("aep_...")
resp, _ := client.Emit(context.Background(), event)The Go SDK also provides AsyncClient (concurrent EmitBatch), ValidateEvent(), and SignEvent() / VerifySignature(). See the Go SDK README for full usage.
If there is no SDK for your language, emitting is a plain JSON POST to /events. The minimal Node.js helper below constructs the envelope, generates IDs and timestamps, and posts the event — port the same pattern to any HTTP-capable language.
const crypto = require("crypto");
const AEP_URL = process.env.AEP_INGEST_URL || "http://localhost:8787";
const AEP_API_KEY = process.env.AEP_API_KEY || "";
const SOURCE = "agent://my-agent";
let sessionId, traceId;
function initSession() {
sessionId = `ses_${crypto.randomUUID()}`;
traceId = `trc_${crypto.randomUUID()}`;
}
async function emit(type, payload, causationId = null) {
const event = {
specversion: "0.2.0",
id: `evt_${crypto.randomUUID().replace(/-/g, "")}`,
time: new Date().toISOString(),
source: SOURCE,
type,
session_id: sessionId,
trace_id: traceId,
payload
};
if (causationId) event.causation_id = causationId;
const res = await fetch(`${AEP_URL}/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${AEP_API_KEY}`
},
body: JSON.stringify(event)
});
const body = await res.json();
return { ...body, eventId: event.id };
}Using Python or Go? Prefer the official SDKs above instead of a hand-rolled POST — they validate, sign, and retry for you. This raw helper is only for languages AEP doesn't yet ship a client for. For other languages (Rust, Java, etc.), the pattern is identical: construct a JSON object matching the envelope schema (Section 4) and POST it to
/events. AEP is language-agnostic.
The causation_id field is the key to building a readable event timeline. Each time an event directly triggers the next action, pass the previous event's id forward. Here is what a typical chain looks like:
// 1. Agent receives a task
const r1 = await emit("task.created", { task: "Summarize Q4 report" });
// 2. Agent calls a tool, caused by the task
const r2 = await emit("tool.called",
{ tool_name: "doc_reader", arguments: { file: "q4.pdf" } },
r1.eventId // chains to task.created
);
// 3. Tool returns a result
const r3 = await emit("tool.result",
{ tool_name: "doc_reader", output: { pages: 12, text: "..." } },
r2.eventId // chains to tool.called
);
// 4. Agent completes the task
const r4 = await emit("task.completed",
{ summary: "Revenue grew 15% YoY..." },
r3.eventId // chains to tool.result
);In the dashboard, this chain renders as a connected timeline: task.created → tool.called → tool.result → task.completed, with full payload details at each step, and as a directed acyclic graph in the Causation DAG view.
Use this table as a quick reference for deciding which event type to emit at each point in your agent's logic.
| Your Agent Does This... | Emit This Event | Example Payload |
|---|---|---|
| Receives a user request or task | task.created |
{ task: "...", priority: "high" } |
| Updates progress on a task | task.updated |
{ status: "in_progress", progress: 50 } |
| Finishes a task successfully | task.completed |
{ result: "...", duration_ms: 1200 } |
| Fails to complete a task | task.failed |
{ error: "timeout", retryable: true } |
| Calls an external API or tool | tool.called |
{ tool_name: "search", arguments: {...} } |
| Gets a response from a tool | tool.result |
{ tool_name: "search", output: {...} } |
| Reads from a knowledge base | memory.read |
{ source: "kb", query: "..." } |
| Stores something in memory | memory.write |
{ key: "summary", value: "..." } |
| Hands off to another agent | handoff.started |
{ to_agent: "specialist", reason: "..." } |
| Handoff is acknowledged | handoff.completed |
{ from_agent: "specialist" } |
| A guardrail blocks an action | policy.blocked |
{ policy: "...", reason: "..." } |
| An unexpected error occurs | error.raised |
{ error: "...", stack: "..." } |
AEP v0.2.0 adds first-class support for multi-agent and sub-agent architectures. Two optional envelope fields tie the hierarchy together:
agent_role— set to"orchestrator"on the coordinating agent,"subagent"on agents spawned by it, and"standalone"for single agents with no parent/child relationship.parent_session_id— on each sub-agent, set this to thesession_idof the agent that spawned it.
AEP uses these fields to reconstruct the full workflow tree automatically — no external mapping required.
Orchestrator agent (runs first, spawns sub-agents):
// Orchestrator sets its own session up normally
initSession(); // generates sessionId, traceId
const orchestratorSessionId = sessionId;
const sharedTraceId = traceId;
await emit("task.created", { task: "Research AI safety landscape" });
await emit("handoff.started", { to_agent: "web-search-agent" });Sub-agent (spawned by the orchestrator):
// Sub-agent uses the SAME traceId but its OWN sessionId
sessionId = `ses_${crypto.randomUUID()}`;
traceId = sharedTraceId; // shared with orchestrator
const event = {
// ... standard fields ...
session_id: sessionId,
trace_id: traceId,
agent_role: "subagent",
parent_session_id: orchestratorSessionId // links back to parent
};In the dashboard's Workflows view, AEP assembles all sessions sharing the same trace_id into a collapsible nested tree, using parent_session_id to determine the hierarchy.
Here is a complete, runnable Node.js example of a support agent with AEP integrated. This mirrors the support-agent-demo.js included in the repository.
const crypto = require("crypto");
const AEP_URL = process.env.AEP_INGEST_URL || "http://localhost:8787";
const AEP_API_KEY = process.env.AEP_API_KEY || "";
const SOURCE = "agent://support-agent";
const sessionId = `ses_support_${crypto.randomUUID()}`;
const traceId = `trc_support_${crypto.randomUUID()}`;
async function emit(type, payload, causationId = null) {
const event = {
specversion: "0.2.0",
id: `evt_${crypto.randomUUID().replace(/-/g, "")}`,
time: new Date().toISOString(),
source: SOURCE,
type,
session_id: sessionId,
trace_id: traceId,
payload
};
if (causationId) event.causation_id = causationId;
const res = await fetch(`${AEP_URL}/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${AEP_API_KEY}`
},
body: JSON.stringify(event)
});
return { ...(await res.json()), eventId: event.id };
}
async function handleTicket(ticketId, issue) {
// 1. Task received
const r1 = await emit("task.created", { ticket_id: ticketId, issue });
// 2. Look up knowledge base
const r2 = await emit("memory.read",
{ knowledge_base: "auth-runbook", query: issue },
r1.eventId
);
// 3. Call ticketing system
const r3 = await emit("tool.called",
{ tool_name: "ticketing.lookup", arguments: { ticket_id: ticketId } },
r2.eventId
);
// ... your actual tool call logic here ...
// 4. Tool returns result
const r4 = await emit("tool.result",
{ tool_name: "ticketing.lookup", output: { last_error: "MFA assertion mismatch" } },
r3.eventId
);
// 5. Task completed
await emit("task.completed",
{ resolution: "Reset MFA binding", escalation_required: false },
r4.eventId
);
console.log("Done. Session:", sessionId);
}
handleTicket("SUP-4217", "Cannot login after SSO migration");Run a pre-built version of this with:
npm run demo:supportSDK example apps: For the same end-to-end pattern using the official clients, see the runnable multi-agent demos in
sdks/python/demos/andsdks/go/examples/.
Once the ingest server is running and your agent is emitting events, open the dashboard:
http://localhost:8787/dashboard
If DASHBOARD_TOKEN is set, the dashboard shows a login overlay on first load. Enter the token to sign in — it is stored in sessionStorage and sent as Authorization: Bearer on all subsequent API calls. You can also deep-link with ?token=<DASHBOARD_TOKEN>.
The dashboard has two top-level tabs and updates in real time via a Server-Sent Events connection.
Sessions view — lists all agent sessions sorted by most recent activity. Selecting a session opens a right panel with two sub-tabs:
- Timeline — chronological event cards with type, timestamp, causation chain, payload, and replay controls (
Prev,Autoplay,Next). Filter events by type or free-text search; export as JSON or CSV. - Causation DAG — an SVG directed acyclic graph laying out events by causation depth, roots on the left and caused events flowing right. Cross-session causation edges appear as dashed stubs labelled "↗ cross-session".
Workflows view — groups sessions by trace_id and renders the agent hierarchy as a collapsible nested tree. Each node shows the session ID, agent_role badge, event count, and age. Clicking a node navigates to that session in the Sessions view. This is the primary view for multi-agent workflows.
Real-time updates — the dashboard connects to GET /stream on load and receives a push frame within milliseconds of every accepted event. A green pulsing Live indicator shows connection health. There is no polling.
After npm install, the aep CLI is available via npx aep (or globally after npm link). It is useful during development for testing your integration, inspecting sessions, and validating event JSON without writing code.
aep --help # global usage
aep <command> --help # per-command helpGlobal flags on all commands: --server <url> (default http://localhost:8787), --key <api-key>. Both can be set via AEP_SERVER and AEP_API_KEY environment variables.
| Command | What it does | Example |
|---|---|---|
aep emit |
Emit a single event to the ingest server | aep emit --type task.created --source agent://x --session ses_abc --trace trc_xyz --key $AEP_API_KEY --payload '{"task":"test"}' |
aep session <id> |
Print the event timeline for a session | aep session ses_abc --key $AEP_API_KEY |
aep export <id> |
Export session events to JSON or CSV | aep export ses_abc --format csv --out events.csv --key $AEP_API_KEY |
aep workflow <traceId> |
Print the full multi-agent workflow tree | aep workflow trc_xyz --key $AEP_API_KEY |
aep validate <file> |
Validate a local event JSON file | aep validate examples/sample-event.json |
Interactive API docs are also available at http://localhost:8787/docs (Swagger UI) — useful for exploring endpoints without writing curl commands.
Authentication requirements: [key:write] = API key with write scope; [key:read or dash] = API key with read scope OR dashboard token; [admin] = ADMIN_TOKEN bearer; [metrics] = METRICS_TOKEN bearer; [none] = no auth.
API versioning: the consumer-facing endpoints below are also served under the
/v1prefix (e.g.POST /v1/events). The unversioned paths shown remain supported for backward compatibility, and every response includes anAPI-Version: 1header. Infra endpoints (/health,/ready,/metrics/prometheus), the dashboard/docs UI (/dashboard,/docs,/openapi.json), and/admin/*are not versioned.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /events |
key:write | Ingest a single event. Returns 202 (accepted), 200 (duplicate), 400 (validation error), 401 (auth failure). |
| GET | /health |
none | Liveness probe. Returns { ok, service, version, checks: { db } }. HTTP 503 if DB unreachable. |
| GET | /ready |
none | Readiness probe. HTTP 200 only when DB is connected and migrations have run. Use for Kubernetes readinessProbe. |
| GET | /metrics |
key:read or dash | Counters: received, accepted, rejected, duplicates, by-type breakdown, session/workflow counts, max tree depth. |
| GET | /metrics/prometheus |
metrics | Prometheus text format 0.0.4. Open in dev when METRICS_TOKEN is unset; 503 in production until it is set. Scrapers send Authorization: Bearer <token>. |
| GET | /stream |
key:read or dash | Server-Sent Events. Delivers event.received frames in real time. Heartbeat every 15 seconds. |
| GET | /sessions |
key:read or dash | Paginated session list. Query: ?limit=<1-500>, ?cursor=<token>. |
| GET | /sessions/:id/events |
key:read or dash | Ordered event timeline. Query: ?type, ?q, ?limit=<1-1000>, ?cursor. |
| GET | /sessions/:id/export |
key:read or dash | Export events. Query: ?format=json|csv, ?type, ?q. |
| GET | /sessions/:id/tree |
key:read or dash | Session and all descendants as a recursive tree. |
| GET | /workflows/:traceId |
key:read or dash | All sessions sharing a trace_id, assembled into a workflow tree. |
| GET | /dashboard |
dash (if set) | Serves the browser dashboard UI. |
| GET | /openapi.json |
none | OpenAPI 3.1 specification document. |
| GET | /docs |
none | Swagger UI (rendered from CDN). |
| POST | /admin/keys |
admin | Generate a new API key. Body: { tenantId, label?, scopes?, hmacSecret? }. Raw key shown once only. |
| GET | /admin/keys |
admin | List all API keys. Raw keys and secrets never returned. |
| DELETE | /admin/keys/:id |
admin | Revoke a key immediately. |
For Docker Compose and direct Docker run instructions, see the Docker section in README.md. The compose file mounts a named Docker volume (aep_data) for the SQLite database, maps port 8787 to the host (overridable via HOST_PORT in .env), and includes a built-in HEALTHCHECK that polls GET /health.
Running AEP as a service? For storage-backend selection (SQLite vs Postgres), multi-tenant projects / tiers / quotas, and the retention / pruning runbook (with cron and Kubernetes
CronJobrecipes), see the dedicated OPERATIONS.md guide. The notes below remain the quick reference for a single-node deployment.
Set ADMIN_TOKEN and DASHBOARD_TOKEN before any network-accessible deployment. See AUTH.md for the full auth reference: API key scopes, HMAC signing setup, tenant isolation, dashboard token configuration, security notes, and key rotation guidance.
Per-API-key rate limiting is built in. The ingest endpoint enforces a fixed-window limit (default 300 req/min per key, configurable via RATE_LIMIT_RPM). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Exceeding the limit returns HTTP 429 with a Retry-After header. Set RATE_LIMIT_RPM=0 to disable.
Events and session metadata are stored in SQLite at data/aep.db by default and survive server restarts. The path is overridable via DATABASE_PATH. For higher throughput or multi-node deployments, switch to the built-in PostgreSQL backend (STORAGE_BACKEND=postgres + DATABASE_URL) — see OPERATIONS.md §1–2 — or place a message queue (Kafka, SQS) in front of the ingest endpoint.
Deduplication is enforced by a unique index on events.id in SQLite, detecting duplicates atomically without a prior read. At high scale with a distributed ingest tier, supplement with a Redis-backed dedupe layer using TTL expiry.
For high-throughput deployments, put a message queue (SQS, Kafka) in front of the ingest endpoint and process events asynchronously. The schema is designed with a PostgreSQL migration in mind.
Use the optional schema field in the envelope to version your payloads as they evolve. The payload.$schema field enables per-payload validation against schemas in schemas/payloads/ — useful for enforcing structure on specific event types as your integration matures.
GET /metrics/prometheus exports event counters, per-type breakdowns, HTTP request counts, and latency histograms in Prometheus text format 0.0.4. Wire this into your existing Prometheus/Grafana stack for production monitoring. In production, set METRICS_TOKEN and pass it to your scraper via the Prometheus authorization scrape config — the endpoint returns 503 until the token is set (see SECURITY.md §8).
Already instrumented with OpenTelemetry? You can export spans to AEP without rewriting your code — add the AEPSpanExporter to your tracer provider:
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from aep.otel import AEPSpanExporter
provider.add_span_processor(
BatchSpanProcessor(AEPSpanExporter(server_url="http://localhost:8787", api_key="..."))
)Spans are mapped to AEP event types with trace context preserved — trace_id carries through to the AEP trace/session and the parent span ID becomes causation_id, so multi-agent causation chains are reconstructed automatically. For mapping rules, batch tuning, and full examples, see sdks/python/aep/otel/README.md.
End of guide. For the full version history and migration guides, see CHANGELOG.md.