Version: 2.1 Date: 2026-04-05 Status: Draft Authors: timoncool
TRAIL is a lightweight, zero-dependency protocol for tracking content across isolated MCP servers. Each server maintains its own append-only log; the LLM orchestrator reads all logs and correlates entries by a universal Content ID. TRAIL enables deduplication, pipeline debugging, crash recovery, and audit trails — without shared state or inter-server communication.
MCP servers are isolated by design — each server has no visibility into other servers. When multiple MCP servers participate in a content pipeline (e.g., fetch from an aggregator → post to a messenger → cross-post to a social network), there is no standard way to:
- Track what content was published where
- Prevent duplicate publications
- Debug broken pipelines
- Audit the full lifecycle of a piece of content
- Recover from orchestrator crashes mid-pipeline
The MCP specification intentionally does not address server-to-server communication. The host (LLM agent) is the sole orchestrator. TRAIL leverages that architecture: each server maintains its own log, the orchestrator reads all logs and connects the dots.
- Each server owns its log. No shared state, no central database, no inter-server communication.
- The orchestrator connects the dots. The LLM reads logs from all servers and traces content by
content_id. - Append-only. Logs are immutable audit trails. Never edit or delete entries.
- Self-documenting. Field names are human-readable. The log is understandable without consulting the spec.
- Convention over configuration. Same file name, same format, same fields everywhere.
- Zero dependencies. JSONL + stdlib. No external libraries required.
- Correlation-ready. Entries can be linked into traces via optional
trace_id, enabling integration with OpenTelemetry and other observability systems.
<mcp-server-root>/data/trail.jsonl
- Default path:
data/trail.jsonlrelative to the MCP server root - Override: Servers MAY allow overriding via
TRAIL_PATHenvironment variable or constructor parameter. When set, the value is used as the full path to the JSONL file. - Format: JSONL (JSON Lines) — one JSON object per line, UTF-8,
\nline endings - Write mode: Append-only
- Concurrency: Serialize writes (mutex/lock). Reads are lock-free.
- Max line length: 64 KB (entries exceeding this SHOULD be truncated in
details)
{"version":2,"timestamp":"2026-04-05T14:07:00.123Z","content_id":"civitai:image:12345","action":"posted","requester":"daily-post","server":"telegram-mcp","details":{"platform":"telegram","message_id":42}}| Field | Type | Description |
|---|---|---|
version |
int |
Protocol version. 2 for this spec. |
timestamp |
string |
ISO 8601 timestamp in UTC with milliseconds. When the action occurred. Example: "2026-04-05T14:07:00.123Z" |
content_id |
string |
Universal Content ID. Follows content across servers. Format: source:type:id. See Content ID. |
action |
string |
Action performed. See Standard Actions. |
requester |
string |
The workflow, scheduled task, or user that initiated this action. Matches the scheduler's task ID or a human-readable pipeline name. |
| Field | Type | Description |
|---|---|---|
details |
object |
Platform-specific data. See Details Field. |
trace_id |
string |
Groups related entries across servers into a single trace. See Trace Correlation. |
server |
string |
Identifier of the MCP server that wrote this entry. See Server Field. |
entry_id |
string |
Unique ID for this entry. Format: UUIDv7 or {server}:{timestamp_ms}:{seq}. See Entry ID. |
caused_by |
string |
entry_id of the entry that directly caused this one. See Causality Chain. |
tags |
string[] |
Free-form labels for filtering and categorization. Example: ["nsfw", "priority:high", "batch:2026-04-05"] |
Format: source:type:id
civitai:image:12345
unsplash:photo:abc-def
youtube:video:dQw4w9WgXcQ
runware:image:550e8400
custom:article:my-slug
Rules:
source— origin platform, lowercase, alphanumeric and hyphens only, no colons. Max 32 characters.type— content type at the source. Standard types:image,video,audio,model,prompt,post,article,document. Custom types allowed, lowercase. Max 32 characters.id— identifier at the source, as-is (string or numeric). Max 256 characters. Must not contain newlines.- The Content ID is assigned at the source and carried unchanged through every server in the pipeline
Validation regex: ^[a-z0-9][a-z0-9-]{0,31}:[a-z0-9][a-z0-9-]{0,31}:[^\n:]{1,256}$
| Action | Meaning |
|---|---|
fetched |
Content retrieved from source (candidate list) |
selected |
Chosen from candidates by the orchestrator |
posted |
Successfully published to this platform |
failed |
Publish attempt failed. Details in details.error |
skipped |
Intentionally not used. Reason in details.reason |
retrying |
Previous attempt failed, scheduling retry. details.attempt has the attempt number |
transformed |
Content was modified (resized, transcoded, translated, etc.). details.transformation describes what changed |
moderated |
Content passed or failed moderation. details.result is "pass" or "reject", details.reason explains why |
expired |
Content is no longer eligible (TTL passed, source removed, etc.) |
delivered |
Delivery confirmed by the platform (e.g., webhook callback, read receipt) |
delegated |
Content/task delegated to another agent or server. details.delegate_to identifies the target, details.delegation_reason explains why |
received |
Content received from another server or agent (counterpart of delegated). details.received_from identifies the source |
evaluated |
Content quality or relevance evaluated. details.score has the result (0.0–1.0), details.evaluator names the method |
guarded |
Content checked by a guardrail. details.guardrail names it, details.passed is boolean, details.reason explains |
acknowledged |
Human-in-the-loop acknowledgment. details.acknowledged_by identifies who, details.decision is "approve" or "reject" |
Servers MAY define additional actions for internal use (e.g., drafted, reviewed, voted). Custom actions MUST be lowercase, single-word or hyphenated, max 32 characters.
The details field is an open object. Each server defines what it stores. However, certain sub-fields have standard meaning when present:
| Field | Type | When |
|---|---|---|
details.error |
object |
On failed or retrying actions |
details.error.type |
string |
Error category: rate_limit, auth, validation, network, server, timeout, unknown |
details.error.message |
string |
Human-readable error description |
details.error.retry_after |
int |
Seconds until retry is allowed (for rate limits) |
details.reason |
string |
Why content was skipped or moderated |
details.platform |
string |
Target platform identifier (e.g., "telegram", "facebook") |
details.platform_id |
string |
ID of the created resource on the target platform |
details.url |
string |
URL of the published content |
details.attempt |
int |
Retry attempt number (1-based), for retrying and failed |
details.transformation |
string |
What transformation was applied (e.g., "resize:1024x1024", "translate:en→ru") |
details.result |
string |
Moderation result: "pass" or "reject" |
details.cost |
object |
Cost information. See Cost Tracking. |
details.content |
object |
Content metadata. See Content Metadata. |
details.duration_ms |
int |
How long the action took in milliseconds |
details.delegate_to |
string |
Target server/agent for delegated actions |
details.delegation_reason |
string |
Why the delegation happened |
details.received_from |
string |
Source server/agent for received actions |
details.score |
number |
Evaluation score (0.0–1.0) for evaluated actions |
details.evaluator |
string |
Evaluation method (e.g., "llm-judge", "heuristic", "human") |
details.guardrail |
string |
Guardrail name for guarded actions |
details.passed |
boolean |
Whether the guardrail passed |
details.acknowledged_by |
string |
Who acknowledged (for acknowledged actions) |
details.decision |
string |
Human decision: "approve" or "reject" |
Source server (content aggregator):
{"details": {"url": "https://civitai.com/images/12345", "content": {"type": "image", "width": 1024, "height": 1024, "model": "Flux.1"}}}Messenger (Telegram, Slack, Discord):
{"details": {"platform": "telegram", "platform_id": "42", "chat_id": "-100273..."}}Social network (Facebook, VK, Reddit):
{"details": {"platform": "vk", "platform_id": "99", "url": "https://vk.com/wall-123_99"}}On failure:
{"details": {"error": {"type": "rate_limit", "message": "429 Too Many Requests", "retry_after": 30}}}On skip:
{"details": {"reason": "nsfw_detected"}}On moderation:
{"details": {"result": "reject", "reason": "copyright_claim", "duration_ms": 1200}}On delegation:
{"details": {"delegate_to": "image-optimizer-mcp", "delegation_reason": "image_too_large"}}On evaluation:
{"details": {"score": 0.87, "evaluator": "llm-judge", "duration_ms": 450}}On guardrail:
{"details": {"guardrail": "nsfw-filter", "passed": false, "reason": "explicit_content_detected"}}On human acknowledgment:
{"details": {"acknowledged_by": "editor@company.com", "decision": "approve"}}When a publishing action has an associated cost, it SHOULD be recorded in details.cost:
{
"details": {
"cost": {
"tokens_in": 150,
"tokens_out": 50,
"usd": 0.003,
"credits": 1
}
}
}| Field | Type | Description |
|---|---|---|
tokens_in |
int |
Input tokens consumed |
tokens_out |
int |
Output tokens generated |
usd |
number |
Cost in US dollars |
credits |
number |
Platform-specific credits consumed |
All cost fields are optional. Include whichever are available.
When content metadata is known, it SHOULD be recorded in details.content:
{
"details": {
"content": {
"type": "image",
"width": 1024,
"height": 1024,
"size_bytes": 245000,
"mime_type": "image/jpeg",
"model": "Flux.1 [dev]",
"title": "Cyberpunk cityscape"
}
}
}| Field | Type | Description |
|---|---|---|
type |
string |
image, video, audio, text, document |
width |
int |
Width in pixels |
height |
int |
Height in pixels |
duration_sec |
number |
Duration in seconds (video/audio) |
size_bytes |
int |
File size in bytes |
mime_type |
string |
MIME type |
model |
string |
AI model used to generate the content |
title |
string |
Content title or caption |
nsfw |
boolean |
Whether content is NSFW |
The optional server field identifies which MCP server wrote the entry. While the orchestrator can infer the server from the file path (each server has its own trail.jsonl), the server field makes entries self-describing — critical for aggregated logs, OTel export, and cross-server debugging.
Format: Lowercase, alphanumeric and hyphens, max 64 characters.
{"version":2,"timestamp":"2026-04-05T14:07:05Z","content_id":"civitai:image:12345","action":"posted","requester":"daily-post","server":"telegram-mcp","details":{"platform":"telegram","platform_id":"42"}}When present, server SHOULD match the server name used in MCP server configuration. Implementations SHOULD set server automatically on every append() call.
The optional trace_id field groups related entries across multiple servers into a single logical trace. This enables:
- Reconstructing the full pipeline for a single orchestrator run
- Integration with OpenTelemetry (use
trace_idas the OTel trace ID) - Correlating entries that belong to the same batch/workflow execution
{"version":2,"timestamp":"2026-04-05T14:07:00Z","content_id":"civitai:image:12345","action":"selected","requester":"daily-post","trace_id":"d4c5f6a7-8b9c-4d0e-a1f2-b3c4d5e6f7a8"}
{"version":2,"timestamp":"2026-04-05T14:07:05Z","content_id":"civitai:image:12345","action":"posted","requester":"daily-post","trace_id":"d4c5f6a7-8b9c-4d0e-a1f2-b3c4d5e6f7a8","details":{"platform":"telegram","platform_id":"42"}}Format: UUID (v4 or v7) or any string up to 64 characters. When bridging to OpenTelemetry, use a 32-character lowercase hex string (W3C Trace Context format).
The orchestrator SHOULD generate one trace_id per pipeline run and pass it to all servers.
The optional entry_id uniquely identifies each entry. This enables caused_by references and deduplication of retried writes.
Recommended formats:
- UUIDv7 (time-sortable):
019576a0-7c00-7000-8000-000000000001 - Composite:
{server}:{timestamp_ms}:{seq}— e.g.,telegram:1743861600123:1
The optional caused_by field links entries into a causality chain. It references the entry_id of the entry that directly led to this one.
{"version":2,"timestamp":"2026-04-05T14:07:00Z","content_id":"civitai:image:12345","action":"selected","requester":"daily-post","entry_id":"civitai:1743861620000:1"}
{"version":2,"timestamp":"2026-04-05T14:07:05Z","content_id":"civitai:image:12345","action":"posted","requester":"daily-post","entry_id":"telegram:1743861625000:1","caused_by":"civitai:1743861620000:1","details":{"platform":"telegram","platform_id":"42"}}This is useful for:
- Tracing exactly which selection led to which post
- Understanding retry chains (failed → retrying → posted)
- Building DAG visualizations of the pipeline
Every MCP server implementing TRAIL MUST expose these tools:
Query the trail log with filters.
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
content_id |
string |
— | Filter by content ID. Exact match, or prefix ending with : (e.g., civitai:image: matches all Civitai images, but civitai:image:1 matches only that exact ID). |
action |
string |
— | Filter by action. |
requester |
string |
— | Filter by requester. |
trace_id |
string |
— | Filter by trace ID. |
server |
string |
— | Filter by server name. |
tags |
string[] |
— | Filter entries that have ALL specified tags. |
since |
string |
— | ISO 8601 timestamp. Only return entries after this time. |
limit |
int |
50 |
Max entries to return, newest first. 0 = unlimited. |
offset |
int |
0 |
Number of entries to skip (for pagination). |
Returns: { entries: TrailEntry[], total: int } — Array of log entries (newest first) and total count matching filters.
Explicitly write an entry to the trail log.
Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
content_id |
string |
yes | Content ID. |
action |
string |
yes | Action. |
requester |
string |
yes | Requester. |
details |
object |
no | Details. |
trace_id |
string |
no | Trace correlation ID. |
entry_id |
string |
no | Unique entry identifier. If omitted, the server MAY auto-generate one. |
caused_by |
string |
no | entry_id of the entry that caused this one. |
tags |
string[] |
no | Tags for this entry. |
Returns: The written entry including server-generated timestamp and optionally entry_id (if auto-generated or provided).
Get summary statistics for the trail log. Useful for dashboards and health checks.
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
requester |
string |
— | Filter by requester. |
since |
string |
— | Only count entries after this time. |
Returns:
{
"total_entries": 1234,
"by_action": {"posted": 500, "fetched": 400, "selected": 200, "failed": 80, "skipped": 54},
"unique_content_ids": 350,
"first_entry": "2026-01-15T10:00:00Z",
"last_entry": "2026-04-05T14:07:00Z"
}Publishing tools (e.g., send_photo, publish_post) SHOULD accept optional TRAIL context parameters. When provided, the tool automatically appends a posted entry on success, or a failed entry on error. This eliminates the need for a separate mark_trail call after every publish.
To standardize how TRAIL context is passed to publishing tools, servers SHOULD accept an optional _trail parameter as a single object containing all trail-related fields:
{
"tool": "send_photo",
"params": {
"photo": "https://example.com/image.jpg",
"chat_id": "-100123456",
"_trail": {
"content_id": "civitai:image:12345",
"requester": "daily-post",
"trace_id": "run-001",
"caused_by": "curator:1743861620000:1",
"tags": ["nsfw", "batch:2026-04-05"]
}
}
}Convention rules:
- The
_trailparameter is OPTIONAL. When absent, no trail entry is written. - The underscore prefix (
_trail) signals that this is protocol metadata, not a tool-specific parameter. _trail.content_idand_trail.requesterare REQUIRED within_trail.trace_id,caused_by, andtagsare optional.- On success, the server appends a trail entry with
action: "posted"and includes platform-specific details automatically (e.g.,platform_id,url). - On failure, the server appends a trail entry with
action: "failed"and includes structured error details. - Servers MAY also accept
content_id,requester,trace_idas separate top-level parameters for backward compatibility. When both_trailand top-level parameters are present,_trailtakes precedence.
An MCP server implementing TRAIL SHOULD advertise support via the trail capability in its server metadata:
{
"capabilities": {
"trail": {
"version": 2,
"server": "telegram-mcp",
"conformance": "standard",
"actions": ["fetched", "selected", "posted", "failed", "skipped", "guarded"],
"auto_log_tools": ["send_photo", "send_message", "publish_post"],
"supports": {
"trace_id": true,
"entry_id": true,
"caused_by": true,
"tags": true,
"server_field": true
},
"retention_days": 90
}
}
}This allows the orchestrator to:
- Know which servers support TRAIL without trial and error
- Discover which publishing tools auto-log
- Verify protocol version compatibility
- Understand which optional features are supported
- Know the server's conformance level and retention policy
A scheduled task daily-content fetches a top image from an aggregator, posts to Telegram, then cross-posts to VK.
{"version":2,"timestamp":"2026-04-05T14:07:00.100Z","content_id":"civitai:image:12345","action":"fetched","requester":"daily-content","trace_id":"t-20260405-001","details":{"url":"https://civitai.com/images/12345","content":{"type":"image","model":"Flux.1","width":1024,"height":1024}}}
{"version":2,"timestamp":"2026-04-05T14:07:00.200Z","content_id":"civitai:image:12346","action":"fetched","requester":"daily-content","trace_id":"t-20260405-001","details":{"url":"https://civitai.com/images/12346","content":{"type":"image","model":"SDXL"}}}
{"version":2,"timestamp":"2026-04-05T14:07:01.000Z","content_id":"civitai:image:12345","action":"selected","requester":"daily-content","trace_id":"t-20260405-001"}
{"version":2,"timestamp":"2026-04-05T14:07:01.100Z","content_id":"civitai:image:12346","action":"skipped","requester":"daily-content","trace_id":"t-20260405-001","details":{"reason":"prompt_too_short"}}{"version":2,"timestamp":"2026-04-05T14:07:05.000Z","content_id":"civitai:image:12345","action":"posted","requester":"daily-content","trace_id":"t-20260405-001","details":{"platform":"telegram","platform_id":"42","chat_id":"-100273..."}}{"version":2,"timestamp":"2026-04-05T14:07:08.000Z","content_id":"civitai:image:12345","action":"failed","requester":"daily-content","trace_id":"t-20260405-001","details":{"error":{"type":"rate_limit","message":"Too many requests","retry_after":60},"attempt":1}}
{"version":2,"timestamp":"2026-04-05T14:08:10.000Z","content_id":"civitai:image:12345","action":"retrying","requester":"daily-content","trace_id":"t-20260405-001","details":{"attempt":2}}
{"version":2,"timestamp":"2026-04-05T14:08:12.000Z","content_id":"civitai:image:12345","action":"posted","requester":"daily-content","trace_id":"t-20260405-001","details":{"platform":"vk","platform_id":"99","url":"https://vk.com/wall-123_99","attempt":2}}The orchestrator queries any server's trail with trace_id=t-20260405-001 and reconstructs the full pipeline:
aggregator → fetched(12345, 12346) → selected(12345) → skipped(12346)
telegram → posted(12345, msg #42)
vk → failed(12345, rate_limit) → retrying(12345) → posted(12345, post #99)
Servers do NOT enforce deduplication themselves. The orchestrator is responsible:
- Before fetching: call
get_trail(requester="daily-content", since="2026-04-04T00:00:00Z")on the source server to see what was recently fetched - Before posting: call
get_trail(content_id="civitai:image:12345", action="posted")on the destination server to check if already posted - Source servers MAY offer a convenience parameter (e.g.,
exclude_used=true) that pre-filters results using the trail
At typical posting rates (~50 entries/day), a single file handles years of data. For high-volume deployments:
When trail.jsonl exceeds a configurable size threshold (default: 10 MB), the server SHOULD:
- Rename current file:
trail.jsonl→trail.{YYYY-MM}.jsonl(e.g.,trail.2026-03.jsonl) - Optionally compress:
trail.2026-03.jsonl.gz - Start a fresh
trail.jsonl
get_trail MUST query the active trail.jsonl file. It MAY also query rotated files when the since parameter requests data older than the active file's first entry.
Servers MAY define a retention period (e.g., 90 days). Entries older than the retention period MAY be deleted during rotation. Servers MUST document their retention policy.
The version field enables forward evolution. The current version is 2. Future versions may add new optional fields, new standard actions, or new encoding formats.
The specification follows semantic versioning (MAJOR.MINOR.PATCH):
- PATCH (e.g., 2.1.0 → 2.1.1): Typo fixes, clarifications. No schema changes.
- MINOR (e.g., 2.1 → 2.2): New optional fields, new standard actions, new detail sub-fields. Always backward compatible — a v2.1 parser MUST be able to read v2.2 entries.
- MAJOR (e.g., 2.x → 3.0): Breaking changes to required fields or core semantics. The
versionfield in entries increments accordingly.
- Servers MUST write entries with
version: 2under this spec - Servers MUST read entries with
version>= 2: extract known fields, ignore unknown fields, do NOT reject the entry - Servers MAY log a warning when encountering a
versionhigher than what they implement - Servers MUST ignore unknown fields in entries at any level (extensibility)
- The
detailsfield is always open — unknown sub-fields MUST be preserved, not stripped - A parser written for spec 2.x MUST correctly read any 2.y entry (where y > x)
- Deprecated features are announced at least one minor version before removal
- Removed features are listed in a CHANGELOG
- A field MUST NOT be removed within the same major version once it reaches
stablestatus
The details field MAY contain sensitive information (user IDs, API keys in error messages, PII in content titles). Implementations SHOULD:
- Never log API keys, tokens, or passwords in
details - Support a configurable
redact_fieldslist to strip sensitive sub-fields before writing - Document which fields may contain PII
Trail files contain operational data that may be sensitive. Servers SHOULD:
- Store trail files with restrictive permissions (e.g.,
0600) - Not expose
get_trailto unauthorized clients - Consider rate-limiting
get_trailqueries
TRAIL entries can be exported as OpenTelemetry spans for integration with existing observability infrastructure.
| TRAIL Field | OTel Span Field / Attribute |
|---|---|
trace_id |
traceId (if 32 hex chars) or trail.trace_id |
entry_id |
spanId |
caused_by |
parentSpanId (enables tree reconstruction in OTel backends) |
timestamp |
startTime |
timestamp + details.duration_ms |
endTime (if duration is available; otherwise endTime = startTime) |
content_id |
trail.content_id |
action |
name (operation name) |
requester |
trail.requester |
server |
service.name (OTel resource attribute) |
details.* |
trail.details.* (flattened) |
tags |
trail.tags |
| TRAIL Action | OTel Span Kind |
|---|---|
fetched, selected |
CLIENT |
posted, delivered |
PRODUCER |
delegated |
PRODUCER |
received |
CONSUMER |
moderated, evaluated, guarded |
INTERNAL |
acknowledged |
INTERNAL |
failed |
CLIENT (with error status) |
| TRAIL Action | OTel Span Status |
|---|---|
failed |
ERROR with error.type = details.error.type |
skipped |
OK (intentional skip is not an error) |
guarded with details.passed: false |
ERROR with error.type = guardrail_blocked |
| All others | OK |
The caused_by field maps directly to OTel's parentSpanId. This means a TRAIL causality chain becomes a span tree in any OTel-compatible backend (Jaeger, Grafana Tempo, Datadog, etc.):
trace_id: t-001
├── curator-mcp: selected (span_id: A)
│ └── curator-mcp: delegated (span_id: B, parent: A)
│ └── optimizer-mcp: received (span_id: C, parent: B)
│ └── optimizer-mcp: transformed (span_id: D, parent: C)
│ └── telegram-mcp: posted (span_id: E, parent: D)
This bridge is informational. A reference exporter may be provided in examples/otel-bridge/.
Basic (Level 0):
- Create
data/directory in your server root - Implement the
Trailclass (see reference implementations) - Add
get_trailandmark_trailtools - Done — you have TRAIL support
Standard (Level 1):
5. Set server name in Trail constructor — it's auto-included in every entry
6. Add get_trail_stats tool
7. Add content_id, requester, trace_id params to publishing tools (auto-logging)
8. Advertise TRAIL in capabilities with "conformance": "standard"
Full (Level 2):
9. Enable entry_id auto-generation and caused_by support
10. Implement tags filtering in get_trail
11. Accept action as string or string[] in get_trail
12. Add OTel export (optional examples/otel-bridge/)
13. Implement log rotation
14. Advertise "conformance": "full"
Include in your scheduled task prompt:
When posting content, ALWAYS pass content_id, requester, and trace_id parameters to track the content pipeline.
Before posting, check get_trail on the destination to avoid duplicates.
Generate one trace_id per pipeline run (e.g., "daily-YYYY-MM-DD-NNN") and pass it to all servers.
TRAIL defines three conformance levels to let implementations adopt the protocol incrementally. Higher levels build on lower levels.
The minimum viable TRAIL implementation.
Required:
- Append-only JSONL file at
data/trail.jsonl - All five required fields:
version,timestamp,content_id,action,requester get_trailtool withcontent_idfiltermark_trailtool
Optional: Everything else.
This level is sufficient for simple deduplication (is_used) and basic pipeline tracking.
For production deployments that need pipeline debugging and cross-server correlation.
Required (in addition to Level 0):
trace_idfield support in both reading and writingserverfield on every entryentry_idauto-generation (UUIDv7 or composite format)detailsfield with standard sub-fields (error,platform,platform_id,url)get_trail_statstool- Auto-logging on publishing tools
- Discovery via capabilities
For advanced multi-agent architectures with full observability.
Required (in addition to Level 1):
caused_byfield support (causality chains)tagsfield support with filtering- All 15 standard actions recognized
- Multi-action filtering in
get_trail(acceptingactionas string or string[]) - OpenTelemetry export capability
- Log rotation with configurable threshold
Servers MUST declare their conformance level in the trail capability:
{"capabilities": {"trail": {"conformance": "standard"}}}Multiple processes or threads MAY write to the same trail.jsonl concurrently. Implementations MUST ensure entry integrity using one of these strategies:
Serialize all writes through a mutex or file lock. The reference implementations use this approach.
# Python — asyncio.Lock
async with self._lock:
with open(self._path, "a") as f:
f.write(json.dumps(entry) + "\n")On POSIX systems, writes ≤ PIPE_BUF (typically 4096 bytes) to a file opened with O_APPEND are atomic. Since the max entry size is 64 KB, this is NOT sufficient for all entries. Use a lock for entries that may exceed PIPE_BUF.
Buffer entries in memory and flush periodically. Suitable for high-throughput servers (>1000 entries/second). Entries MAY be lost on crash; this is acceptable for non-critical telemetry but NOT for audit trails.
Reads are always lock-free. A reader may see a partially written last line; implementations MUST skip lines that fail JSON parsing.
As LLM orchestrators grow more complex, content pipelines may involve multiple agents working together. TRAIL supports these patterns through standard actions and conventions.
When one agent delegates work to another, the delegating agent logs a delegated entry, and the receiving agent logs a received entry. Both entries share the same trace_id and reference each other via caused_by.
{"version":2,"timestamp":"2026-04-05T14:07:00Z","content_id":"civitai:image:12345","action":"delegated","requester":"content-curator","server":"curator-mcp","entry_id":"curator:1743861620000:1","trace_id":"t-001","details":{"delegate_to":"image-optimizer-mcp","delegation_reason":"needs_resize"}}
{"version":2,"timestamp":"2026-04-05T14:07:02Z","content_id":"civitai:image:12345","action":"received","requester":"content-curator","server":"optimizer-mcp","entry_id":"optimizer:1743861622000:1","caused_by":"curator:1743861620000:1","trace_id":"t-001","details":{"received_from":"curator-mcp"}}
{"version":2,"timestamp":"2026-04-05T14:07:05Z","content_id":"civitai:image:12345","action":"transformed","requester":"content-curator","server":"optimizer-mcp","entry_id":"optimizer:1743861625000:1","caused_by":"optimizer:1743861622000:1","trace_id":"t-001","details":{"transformation":"resize:1024x1024"}}The orchestrator reconstructs the full DAG:
curator: delegated → optimizer: received → transformed
Quality gates can be implemented using the evaluated action. An evaluator (LLM judge, heuristic, or human) scores content before it proceeds in the pipeline.
{"version":2,"timestamp":"2026-04-05T14:07:03Z","content_id":"civitai:image:12345","action":"evaluated","requester":"daily-post","server":"evaluator-mcp","trace_id":"t-001","details":{"score":0.92,"evaluator":"llm-judge","duration_ms":800}}The orchestrator can use the score to decide whether to proceed:
if score >= 0.8: proceed to posting
if score < 0.5: skip with reason "low_quality"
Safety checks are recorded using the guarded action. This creates an audit trail of which guardrails were applied and their results.
{"version":2,"timestamp":"2026-04-05T14:07:01Z","content_id":"civitai:image:12345","action":"guarded","requester":"daily-post","server":"safety-mcp","trace_id":"t-001","details":{"guardrail":"nsfw-classifier","passed":true,"duration_ms":200}}
{"version":2,"timestamp":"2026-04-05T14:07:02Z","content_id":"civitai:image:12345","action":"guarded","requester":"daily-post","server":"safety-mcp","trace_id":"t-001","details":{"guardrail":"copyright-check","passed":true,"duration_ms":1500}}Multiple guardrails can run in sequence or parallel. The orchestrator proceeds only if all guardrails pass.
When human approval is required, the acknowledged action captures the decision with attribution.
{"version":2,"timestamp":"2026-04-05T14:10:00Z","content_id":"civitai:image:12345","action":"acknowledged","requester":"daily-post","server":"approval-mcp","trace_id":"t-001","details":{"acknowledged_by":"editor@company.com","decision":"approve"}}With entry_id, caused_by, and server, the orchestrator can build a full DAG of any pipeline:
curator-mcp: fetched → selected → delegated ─┐
│
optimizer-mcp: received ←─────────┘
↓
transformed
↓
safety-mcp: guarded (nsfw) → guarded (copyright)
↓
evaluator-mcp: evaluated (0.92)
↓
telegram-mcp: posted (#42)
↓
vk-mcp: posted (#99)
| Approach | Shared State | Setup | Agent Patterns | Content Semantics | Use Case |
|---|---|---|---|---|---|
| TRAIL | No | Copy one file | Yes | Yes | Content tracking across MCP servers |
| Shared Database | Yes | Deploy DB + migrations | No | No | General state sharing |
| Message Queue | Yes | Deploy broker | No | No | Real-time event streaming |
| OpenTelemetry | No | SDK + collector + backend | Proposed | No | Tool call tracing |
| IETF AAT | No | Heavy spec | Partial | No | Compliance audit trails |
| Google A2A | No | Full protocol | Yes | No | Agent-to-agent communication |
| Langfuse | Yes (cloud/self-host) | SDK + backend | Partial | No | LLM call observability |
| LangSmith | Yes (cloud) | SDK + cloud | Partial | No | LangChain observability |
| Agent Protocol | No | REST API | Tasks/Steps | No | Agent communication API |
| ActivityPub | Yes (federation) | Full server | No | No | Social federation |
Why TRAIL occupies a unique niche: No other protocol combines zero shared state, content-level semantics (content_id tracking "what was published where"), and multi-agent patterns (delegation, evaluation, guardrails) in a single lightweight spec. Observability tools trace calls; TRAIL tracks content. They are complementary.
Q: Why readable field names instead of short ones?
A: A protocol meant to last decades must be self-documenting. When a developer sees a trail file for the first time, content_id is immediately clear. The ~15 KB/year overhead at typical rates is negligible, and gzip eliminates it entirely.
Q: Why not a shared database? A: MCP servers are isolated by design. A shared DB creates coupling, deployment complexity, and a single point of failure. The orchestrator already sees all servers — let it do the correlation.
Q: Are all optional fields really needed?
A: No field is required except the five core ones. Optional fields (trace_id, entry_id, caused_by, tags) unlock advanced use cases (pipeline reconstruction, OTel integration, DAG visualization) without adding burden to simple implementations. If you don't need them, don't include them.
Q: What about log rotation? A: See Log Rotation. At ~50 entries/day, one file handles years. For high-volume deployments, rotate at 10 MB.
Q: Can I add custom fields to the root?
A: No. Put everything custom in details. Root-level fields are reserved for the protocol. This ensures parsers can always rely on the root schema.
Q: What if the orchestrator crashes mid-pipeline?
A: The trail shows exactly where it stopped. Use trace_id to find all entries from that pipeline run. The last entry's action tells you where to resume.
Q: How does TRAIL compare to OpenTelemetry? A: OTel traces tool calls (function invocations, latencies, errors). TRAIL tracks content semantics (what was published where). They are complementary. Use the OTel bridge to combine both in one dashboard.
Q: What are conformance levels?
A: Three tiers: Basic (5 required fields + 2 tools), Standard (adds trace_id, server, auto-logging, discovery), Full (adds causality chains, all 15 actions, OTel export). Start at Basic and upgrade as needed.
Q: How does TRAIL handle multi-agent pipelines?
A: Through delegated/received action pairs, caused_by chains, and the server field. The orchestrator reconstructs the full DAG across all agents. See Multi-Agent Patterns.
Q: Do I need guardrails and evaluation support?
A: Only if your pipeline has quality gates. The guarded and evaluated actions are optional — they exist so that safety checks and scoring are part of the audit trail, not hidden in server logs.
Q: Why not use IETF AAT (Agent Audit Trail)? A: AAT is designed for regulatory compliance (EU AI Act, SOC 2) with hash chains and ECDSA signatures. TRAIL is designed for developer experience — lightweight, zero-dependency, content-first. If you need compliance, consider TRAIL for content tracking and AAT for regulatory auditing.