Skip to content

The two IDs

protoWorkstacean uses two IDs to track causality across the bus, the executor layer, and external A2A calls:

IDHeaderBus fieldMeaning
correlationIdX-Correlation-Idmsg.correlationIdTrace ID — identifies the entire logical flow. Never changes. Set once at the entry point.
parentIdX-Parent-Idmsg.parentIdSpan ID — identifies the immediate cause. Changes at each layer boundary.

This is the same mental model as OpenTelemetry traceId / parentSpanId. The implementation is lightweight (plain strings in bus messages and HTTP headers) but the semantics are equivalent.

Where correlationId is set

correlationId is set once, at message entry:

  • DiscordPlugin: generates crypto.randomUUID() for each @mention
  • GitHubPlugin: uses the GitHub delivery ID
  • POST /publish: caller provides it, or the HTTP handler generates one if omitted
  • SchedulerPlugin: generates one per cron fire

Once set, correlationId must not be changed. Every downstream bus message, every A2A call, every executor invocation carries the same value.

Where parentId is set

parentId is the span boundary. It changes at each meaningful processing step:

  1. RouterPlugin publishes agent.skill.request with parentId = inboundMessage.id. The inbound message's id is the parent span of the skill dispatch.

  2. SkillDispatcherPlugin constructs a SkillRequest with parentId = busMessage.id. This is the critical linkage: the SkillRequest knows which bus message caused it.

  3. A2AExecutor sends X-Parent-Id: req.parentId to the external agent. The receiving service should record this as its parent span and generate its own span ID for its own work.

  4. Agents that respond via agent.skill.response.<correlationId> carry the same correlationId. They may include their own span information in the response payload, but the bus message's id (a new UUID generated by the bus) becomes the parentId for any subsequent messages triggered by the response.

Flow example: Discord mention → protoPen → response

Discord @mention arrives
  bus message {
    id: "msg-001",
    correlationId: "trace-abc",
    parentId: undefined,    ← entry point, no parent
    topic: "message.inbound.discord.1234"
  }

RouterPlugin publishes:
  bus message {
    id: "msg-002",
    correlationId: "trace-abc",
    parentId: "msg-001",    ← Discord message is the parent
    topic: "agent.skill.request",
    payload: { skill: "sitrep", parentId: "msg-001", ... }
  }

SkillDispatcherPlugin constructs SkillRequest:
  {
    skill: "sitrep",
    correlationId: "trace-abc",
    parentId: "msg-002",    ← skill.request bus message is the parent
    replyTopic: "agent.skill.response.trace-abc"
  }

A2AExecutor calls the protoPen server:
  POST /a2a
  Headers:
    X-Correlation-Id: trace-abc
    X-Parent-Id: msg-002
    X-API-Key: ...
  Body: { contextId: "trace-abc", ... }

protoPen processes, publishes response on agent.skill.response.trace-abc:
  bus message {
    id: "msg-003",
    correlationId: "trace-abc",
    parentId: "msg-002",    ← the A2A response is a child of the skill request
    topic: "agent.skill.response.trace-abc"
  }

DiscordPlugin posts the reply.

The full chain is reconstructable from the log: msg-003msg-002msg-001 all share trace-abc.

contextId in A2A responses

The A2A spec uses contextId in the response to carry the trace ID back. A2AExecutor sends contextId: req.correlationId in the request. External A2A agents (protoPen, ORBIS, protoContent) propagate this value in the response. workstacean reads it and confirms the response belongs to the right trace.

If the contextId in the response does not match req.correlationId, the response is a stale reply from a previous conversation and is discarded.

Why not use OpenTelemetry directly

OpenTelemetry is the right long-term answer. The current implementation uses plain strings because:

  1. The bus is the primary observability layer — every message is written to data/events.db with its full payload, and correlationId + parentId are indexed columns
  2. Adding an OTel exporter is a future concern; the IDs are already there when that work happens
  3. The A2A protocol boundary (HTTP headers) is already compatible with OTel trace context — X-Correlation-Id maps to traceparent's trace-id, X-Parent-Id maps to the parent-id field

The implementation was deliberately kept simple to avoid a dependency on an OTel SDK before the instrumentation requirements are known.

protoWorkstacean — a switchboard, not an agent.