fabro/docs/internal/events.md
Bryan Helmkamp 4645181dbf State the agent event contract in the docs
Pebble's CodingAgentEvent stream is the agent event contract: every event
except streaming deltas is stored verbatim under its derived name and
folded into StageProjection.agent with pebble's SessionProjection. The
events doc, the events strategy, and the v2 shape doc say so, list the
agent events fabro still emits for facts pebble cannot know, and tell
consumers to read the fold rather than fold the events again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 08:36:37 -06:00

2286 lines
64 KiB
Markdown

# Events
Every serialized run event envelope, whether streamed over SSE, returned by `fabro events`, or written to a JSONL sink, uses this structure:
```json
{
"id": "019234ab-cdef-7890-abcd-ef1234567890",
"ts": "2026-04-01T12:00:00.123Z",
"run_id": "01JQXYZ...",
"event": "stage.completed",
"session_id": "ses_abc",
"parent_session_id": "ses_parent",
"node_id": "code",
"node_label": "Write Code",
"properties": { ... }
}
```
### Envelope fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | UUID v7 (time-ordered), unique per event |
| `ts` | string | RFC 3339 timestamp with millisecond precision |
| `run_id` | string | ULID of the run |
| `event` | string | Dot-notation event name |
| `session_id` | string? | Agent session id (agent events only) |
| `parent_session_id` | string? | Parent agent session id (agent events only) |
| `node_id` | string? | Node id (stage, checkpoint, agent, parallel branch, and other node-scoped events) |
| `node_label` | string? | Display label for the node (defaults to `node_id` when not set separately) |
| `properties` | object | Event-specific fields |
---
## Run events
### `run.created`
Emitted when the run record is created.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.created",
"properties": {
"workflow_slug": "my-workflow",
"workflow_version_id": "wv_...",
"target": {
"kind": "git",
"repo": "acme/my-project",
"branch": "main",
"sha": "0123456789abcdef0123456789abcdef01234567"
},
"source_directory": "/home/user/src/my-project",
"git": {
"origin_url": "https://github.com/acme/my-project",
"branch": "main",
"sha": "0123456789abcdef0123456789abcdef01234567",
"dirty": "clean"
},
"fork_source_ref": null,
"in_place": false,
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "octocat",
"auth_method": "github"
}
}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `settings` | object | Workflow settings snapshot |
| `graph` | object | Parsed workflow graph |
| `workflow_source` | string? | Workflow source text |
| `labels` | object | Run labels |
| `source_directory` | string? | Submitter-side source directory |
| `workflow_slug` | string? | Workflow slug |
| `workflow_version_id` | string? | Exact immutable root workflow version used for admission |
| `target` | object? | Canonical accepted workspace target. Version-backed Git intent runs persist `kind`, `repo`, required `branch`, and optional normalized `sha`; legacy manifest runs omit it |
| `provenance` | object | Actor and request provenance |
| `manifest_blob` | string? | Blob hash for the submitted manifest |
| `git` | object? | Operational Git projection: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status. For Git intent runs, `branch` is the submitted working branch and `sha` is the optional lowercase-normalized submitted commit; admission does not resolve it or prove branch ancestry. Legacy runs retain their observed optional-SHA semantics |
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |
| `in_place` | boolean | Whether the run was created with `--in-place` (no git checkpoints) |
Readers remain tolerant of the legacy `workflow_config`, `run_dir`, and
`db_prefix` properties, and of a legacy `push_outcome` object nested inside
`git`, when replaying historical events; newly emitted `run.created` events
omit them.
### `run.started`
Emitted when the workflow run begins.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.started",
"properties": {
"name": "my-workflow",
"base_branch": "main",
"base_sha": "abc123...",
"run_branch": "fabro/run-01JQXYZ",
"worktree_dir": "/tmp/fabro-worktrees/...",
"goal": "Fix the login bug"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Workflow name |
| `base_branch` | string? | Base git branch |
| `base_sha` | string? | Base commit SHA |
| `run_branch` | string? | Git branch created for this run |
| `worktree_dir` | string? | Worktree directory path |
| `goal` | string? | Workflow goal text |
Note: `run_id` is in the envelope, not in properties.
### `run.completed`
Emitted when the workflow run finishes successfully (or with partial success).
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.completed",
"properties": {
"duration_ms": 45000,
"artifact_count": 3,
"status": "succeeded",
"total_cost": 0.15,
"final_git_commit_sha": "def456...",
"usage": {
"input_tokens": 15000,
"output_tokens": 5000,
"total_tokens": 20000,
"reasoning_tokens": 2000,
"cache_read_tokens": 8000,
"cache_write_tokens": 3000,
"speed": "standard"
}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `duration_ms` | number | Total run duration in milliseconds |
| `artifact_count` | number | Number of artifacts produced |
| `status` | string | Final stage outcome (`"succeeded"`, `"failed"`, `"partially_succeeded"`, `"skipped"`) |
| `total_cost` | number? | Aggregate cost in USD |
| `final_git_commit_sha` | string? | Final HEAD SHA |
| `usage` | object? | Aggregate token usage |
| `usage.input_tokens` | number | Total input tokens |
| `usage.output_tokens` | number | Total output tokens |
| `usage.total_tokens` | number | Total tokens (input + output) |
| `usage.reasoning_tokens` | number? | Total reasoning/thinking tokens |
| `usage.cache_read_tokens` | number? | Total cache read tokens |
| `usage.cache_write_tokens` | number? | Total cache write tokens |
| `usage.speed` | string? | Speed tier |
| `usage.raw` | object? | Raw provider-specific usage data |
### `run.failed`
Emitted when the workflow run fails.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.failed",
"properties": {
"error": "Handler error: compilation failed",
"duration_ms": 12000,
"git_commit_sha": "abc123..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `error` | string | Error message (Display representation) |
| `duration_ms` | number | Run duration before failure |
| `git_commit_sha` | string? | HEAD SHA at time of failure |
### `run.notice`
Informational, warning, or error notice emitted during the run.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.notice",
"properties": {
"level": "warn",
"code": "missing_env_var",
"message": "GITHUB_TOKEN not set, PR creation will be skipped"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `level` | string | `"info"`, `"warn"`, or `"error"` |
| `code` | string | Machine-readable notice code |
| `message` | string | Human-readable message |
### `run.interrupt`
Emitted after a live worker accepts a run interrupt control operation. The
actor is stored in the top-level `actor` envelope field. Properties are empty.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.interrupt",
"actor": { "kind": "user", "login": "octocat" },
"properties": {}
}
```
### `run.steer`
Emitted after a live worker accepts run steering text. The actor is stored in
the top-level `actor` envelope field.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.steer",
"actor": { "kind": "user", "login": "octocat" },
"properties": {
"text": "Remember to run tests after changes"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `text` | string | Accepted steering text |
### `metadata.snapshot.started`
Historical event, no longer emitted. Recorded when Fabro began a Git metadata snapshot operation. Retained for reading older run streams.
Init and finalize metadata snapshots are unscoped. Checkpoint metadata snapshots use the checkpoint stage scope, so they include the checkpoint `node_id`, `node_label`, and `stage_id`.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "metadata.snapshot.started",
"properties": {
"phase": "checkpoint",
"branch": "fabro/meta"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `phase` | string | Logical metadata operation: `"init"`, `"checkpoint"`, or `"finalize"` |
| `branch` | string | Metadata branch/ref being written |
### `metadata.snapshot.completed`
Historical event, no longer emitted. Recorded when Fabro committed and pushed a metadata snapshot successfully. Retained for reading older run streams.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "metadata.snapshot.completed",
"properties": {
"phase": "checkpoint",
"branch": "fabro/meta",
"duration_ms": 2800,
"entry_count": 12,
"bytes": 18432,
"commit_sha": "def456..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `phase` | string | Logical metadata operation: `"init"`, `"checkpoint"`, or `"finalize"` |
| `branch` | string | Metadata branch/ref that was written |
| `duration_ms` | number | End-to-end duration of the metadata snapshot operation |
| `entry_count` | number | Number of metadata files written into the snapshot commit |
| `bytes` | number | Sum of serialized metadata entry byte lengths |
| `commit_sha` | string | Metadata snapshot commit SHA |
### `metadata.snapshot.failed`
Historical event, no longer emitted. Recorded when a metadata snapshot attempt failed, before the matching compatibility `run.notice`, allowing human-facing consumers to suppress duplicate warning text. Compatibility notices with codes `checkpoint_metadata_write_failed` and `checkpoint_metadata_push_failed` may still appear in raw event streams. The `checkpoint_metadata_degraded` notice is a separate summary signal and should not be treated as a duplicate of this event.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "metadata.snapshot.failed",
"properties": {
"phase": "checkpoint",
"branch": "fabro/meta",
"duration_ms": 900,
"failure_kind": "push",
"error": "failed to push metadata snapshot",
"causes": ["remote rejected the push"],
"commit_sha": "def456...",
"entry_count": 12,
"bytes": 18432
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `phase` | string | Logical metadata operation: `"init"`, `"checkpoint"`, or `"finalize"` |
| `branch` | string | Metadata branch/ref being written |
| `duration_ms` | number | End-to-end duration before failure |
| `failure_kind` | string | Failure phase: `"load_state"`, `"write"`, or `"push"` |
| `error` | string | Primary error summary |
| `causes` | string[] | Error cause chain; omitted when empty |
| `commit_sha` | string? | Local metadata commit SHA for push failures; omitted for load-state and write failures |
| `entry_count` | number? | Metadata entry count for push failures; omitted for load-state and write failures |
| `bytes` | number? | Serialized metadata byte count for push failures; omitted for load-state and write failures |
---
## Stage events
### `stage.started`
Emitted when a workflow node begins execution.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "stage.started",
"node_id": "code",
"node_label": "Write Code",
"properties": {
"index": 1,
"handler_type": "agent",
"attempt": 1,
"max_attempts": 3
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `index` | number | Stage execution order index |
| `handler_type` | string | Handler type (`"agent"`, `"prompt"`, `"command"`, `"conditional"`, `"human"`, `"parallel"`, etc.) |
| `attempt` | number | Current attempt number (1-based) |
| `max_attempts` | number | Maximum attempts allowed |
### `stage.completed`
Emitted when a workflow node finishes execution.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "stage.completed",
"node_id": "code",
"node_label": "Write Code",
"properties": {
"index": 1,
"duration_ms": 8000,
"status": "succeeded",
"preferred_label": "tests_pass",
"suggested_next_ids": ["review"],
"usage": {
"model": "claude-sonnet-4-20250514",
"input_tokens": 5000,
"output_tokens": 2000,
"cache_read_tokens": 3000,
"cache_write_tokens": 1000,
"reasoning_tokens": 500,
"speed": "standard",
"cost": 0.05
},
"error": "lint failed",
"failure_class": "deterministic",
"failure_signature": "clippy::unused_import",
"context_updates": {"response.code": "done"},
"jump_to_node": "review",
"context_values": {"response.code": "done"},
"node_visits": {"code": 1},
"loop_failure_signatures": {"code|deterministic|clippy::unused_import": 2},
"restart_failure_signatures": {"code|transient_infra|timeout": 1},
"response": "done",
"notes": "All tests passing",
"files_touched": ["src/main.rs", "src/lib.rs"],
"attempt": 1,
"max_attempts": 3
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `index` | number | Stage execution order index |
| `duration_ms` | number | Stage duration in milliseconds |
| `status` | string | `"succeeded"`, `"failed"`, `"skipped"`, `"partially_succeeded"` |
| `preferred_label` | string? | Edge label hint for routing |
| `suggested_next_ids` | string[] | Suggested successor node ids |
| `usage` | object? | Token usage for this stage |
| `usage.model` | string | Model identifier |
| `usage.input_tokens` | number | Input tokens |
| `usage.output_tokens` | number | Output tokens |
| `usage.cache_read_tokens` | number? | Cache read tokens |
| `usage.cache_write_tokens` | number? | Cache write tokens |
| `usage.reasoning_tokens` | number? | Reasoning/thinking tokens |
| `usage.speed` | string? | Speed tier |
| `usage.cost` | number? | Estimated cost in USD |
| `billing_by_model` | array? | For an agent stage, the stage's billing split by model: the root session's route and each subagent's own model, a subagent whose model the catalog does not know billed at the root's. Each row has `model`, `tokens`, and `total_usd_micros`, and the rows sum to the stage's billing. Empty for stages without a coding agent and on events written before it existed |
| `error` | string? | Error message (flattened from failure detail) |
| `failure_class` | string? | `"transient_infra"`, `"deterministic"`, `"budget_exhausted"`, `"compilation_loop"`, `"canceled"`, `"structural"` |
| `failure_signature` | string? | Dedup key for repeated failures |
| `context_updates` | object? | Context delta written by this stage |
| `jump_to_node` | string? | Non-edge jump target |
| `context_values` | object? | Context snapshot after the stage, minus runtime-only keys such as `current.preamble`. Artifact pointers are not normalized to blob refs — use `checkpoint.completed` for the durable projection |
| `node_visits` | object? | Node visit counts after the stage |
| `loop_failure_signatures` | object? | Loop failure signature counts |
| `restart_failure_signatures` | object? | Restart failure signature counts |
| `response` | string? | Full LLM or agent response text when produced by the stage |
| `notes` | string? | Free-text notes |
An agent stage's usage is its whole session tree's: the root session and
every subagent, live in `StageProjection.usage` and here at completion, both
read from the same fold of the stage's agent events. The root is priced at
its route and each subagent at its own model; where the provider reported a
cost, that cost stands.
| `files_touched` | string[] | File paths modified |
| `attempt` | number | Attempt number (1-based) |
| `max_attempts` | number | Maximum attempts allowed |
Note: `failure` is flattened — the `failure.message` becomes `error`, `failure.failure_class` becomes `failure_class`, `failure.failure_signature` becomes `failure_signature`.
### `stage.failed`
Emitted when a stage fails (before retry decision).
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "stage.failed",
"node_id": "code",
"node_label": "Write Code",
"properties": {
"index": 1,
"error": "compilation failed",
"failure_class": "deterministic",
"failure_signature": "rustc::E0308",
"will_retry": true
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `index` | number | Stage execution order index |
| `error` | string | Error message (flattened from failure detail) |
| `failure_class` | string | Failure category |
| `failure_signature` | string? | Dedup key for repeated failures |
| `will_retry` | boolean | Whether the stage will be retried |
| `billing` | object? | What the stage spent before it failed, in the shape `stage.completed` uses. An agent stage that fails for good after answering model calls bills its whole session tree, as it would have on completion; a retried attempt and a cancelled stage carry none |
| `billing_by_model` | array? | `billing` split by model, as on `stage.completed` |
### `stage.retrying`
Emitted when a stage is about to be retried.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "stage.retrying",
"node_id": "code",
"node_label": "Write Code",
"properties": {
"index": 1,
"attempt": 2,
"max_attempts": 3,
"delay_ms": 1000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `index` | number | Stage execution order index |
| `attempt` | number | Next attempt number |
| `max_attempts` | number | Maximum attempts allowed |
| `delay_ms` | number | Delay before retry in milliseconds |
### `stage.prompt`
Emitted when a prompt is rendered for an LLM stage.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "stage.prompt",
"node_id": "code",
"node_label": "code",
"properties": {
"text": "You are a coding agent. Fix the bug in..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `text` | string | Rendered prompt text |
---
## Parallel events
### `parallel.started`
Emitted when a parallel node begins executing branches.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "parallel.started",
"properties": {
"visit": 1,
"branch_count": 3
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `visit` | number | Visit number for this parallel stage |
| `branch_count` | number | Number of parallel branches |
### `parallel.branch.started`
Emitted when a parallel branch begins.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "parallel.branch.started",
"node_id": "branch_a",
"node_label": "branch_a",
"properties": {
"index": 0
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `index` | number | Branch index |
### `parallel.branch.completed`
Emitted when a parallel branch finishes.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "parallel.branch.completed",
"node_id": "branch_a",
"node_label": "branch_a",
"properties": {
"index": 0,
"duration_ms": 5000,
"status": "succeeded"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `index` | number | Branch index |
| `duration_ms` | number | Branch duration in milliseconds |
| `status` | string | Branch outcome status |
### `parallel.completed`
Emitted when all parallel branches have finished.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "parallel.completed",
"properties": {
"visit": 1,
"duration_ms": 12000,
"success_count": 2,
"failure_count": 1,
"results": [
{
"id": "branch_a",
"status": "succeeded",
"context_updates": {"response.branch_a": "review complete"}
},
{
"id": "branch_b",
"status": "failed",
"context_updates": {"command.output": "validation failed"}
}
]
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `visit` | number | Visit number for this parallel stage |
| `duration_ms` | number | Total parallel duration |
| `success_count` | number | Branches that succeeded |
| `failure_count` | number | Branches that failed |
| `results` | array | Ordered typed branch results with `id`, `status`, and isolated `context_updates` |
---
## Interview events
### `interview.started`
Emitted when a human-in-the-loop question is posed.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "interview.started",
"node_id": "review",
"node_label": "review",
"properties": {
"question": "Does this look correct?",
"question_type": "approval"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `question` | string | Question text |
| `question_type` | string | Type of question |
### `interview.completed`
Emitted when a human answers.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "interview.completed",
"properties": {
"question": "Does this look correct?",
"answer": "yes",
"duration_ms": 30000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `question` | string | Question text |
| `answer` | string | Human's answer |
| `duration_ms` | number | Time waiting for answer |
### `interview.timeout`
Emitted when a human question times out.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "interview.timeout",
"node_id": "review",
"node_label": "review",
"properties": {
"question": "Does this look correct?",
"duration_ms": 300000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `question` | string | Question text |
| `duration_ms` | number | Time waited before timeout |
---
## Checkpoint events
### `checkpoint.completed`
Emitted after a checkpoint is saved.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "checkpoint.completed",
"node_id": "code",
"node_label": "code",
"properties": {
"status": "succeeded",
"git_commit_sha": "abc123...",
"diff": "diff --git a/src/lib.rs b/src/lib.rs\n..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `status` | string | Checkpoint status |
| `git_commit_sha` | string? | Commit SHA at checkpoint time |
| `diff` | string? | Git diff captured for the checkpointed node |
### `checkpoint.failed`
Emitted when checkpoint saving fails.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "checkpoint.failed",
"node_id": "code",
"node_label": "code",
"properties": {
"error": "git commit failed: ..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `error` | string | Error message |
---
## Git events
### `git.commit`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "git.commit",
"node_id": "code",
"node_label": "code",
"properties": {
"sha": "abc123..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `sha` | string | Commit SHA |
Note: `node_id` is optional — may be absent for non-stage commits.
### `git.push`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "git.push",
"properties": {
"branch": "fabro/run-01JQXYZ",
"success": true
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `branch` | string | Branch name |
| `success` | boolean | Whether push succeeded |
### `git.fetch`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "git.fetch",
"properties": {
"branch": "main",
"success": true
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `branch` | string | Branch name |
| `success` | boolean | Whether fetch succeeded |
### `git.reset`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "git.reset",
"properties": {
"sha": "abc123..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `sha` | string | Target commit SHA |
---
## Routing events
### `edge.selected`
Emitted when the engine selects the next edge to traverse.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "edge.selected",
"properties": {
"from_node": "code",
"to_node": "review",
"label": "tests_pass",
"condition": "outcome=succeeded",
"reason": "condition",
"preferred_label": "tests_pass",
"suggested_next_ids": ["review"],
"stage_status": "succeeded",
"is_jump": false
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `from_node` | string | Source node id |
| `to_node` | string | Target node id |
| `label` | string? | Edge label |
| `condition` | string? | Edge condition expression |
| `reason` | string | Selection reason (`"condition"`, `"preferred_label"`, `"jump"`, etc.) |
| `preferred_label` | string? | Stage's preferred label hint |
| `suggested_next_ids` | string[] | Stage's suggested next node ids |
| `stage_status` | string | Outcome status that influenced routing |
| `is_jump` | boolean | Whether this bypassed normal edge selection |
### `loop.restart`
Emitted when execution loops back to an earlier node.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "loop.restart",
"properties": {
"from_node": "review",
"to_node": "code"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `from_node` | string | Node that triggered the restart |
| `to_node` | string | Node to restart from |
---
## Agent events
Pebble's `CodingAgentEvent` stream is the agent event contract. Every event
the coding agent publishes for a stage, except streaming deltas, is stored
verbatim as an `EventBody::Agent` under a name derived from its variant
(`agent.message`, `agent.tool.started`, `agent.route.failover`,
`agent.mcp.server.ready`, `todo.created`, and so on; the full list is
`CODING_EVENT_NAMES`). Its `properties` are pebble's own envelope, so
pebble's event types are part of fabro's stored format, and the store folds
the same events into `StageProjection.agent` with pebble's
`SessionProjection`, the one fold of that stream.
Fabro emits an agent event of its own only for a fact pebble cannot know:
- `agent.session.activated` and `agent.session.deactivated`: the stage's
route, controls, permission level, and steering capabilities, as fabro
resolved them.
- `agent.tools.available`: the tool catalog fabro handed the agent.
- `agent.pair.user_message` and `agent.pair.system_message`: pair mode.
- `agent.interrupt.injected`, `agent.steer.buffered`, `agent.steer.dropped`:
run-level steering as it reaches, waits for, or misses a session.
- `agent.acp.started`, `agent.acp.completed`, `agent.acp.cancelled`,
`agent.acp.timed_out`: an external ACP agent process, which pebble does
not run.
- `prompt.failover`: a one-shot prompt stage moving to a fallback route,
which it does without pebble.
Every agent activity event is stage-scoped and carries `node_id` (the
workflow stage), `node_label`, `stage_id`, `session_id`, and
`parent_session_id` in the envelope. Pebble's session lifecycle events
(`agent.session.started`, `agent.session.ended`) are stored with the stage
that ran the session like the rest.
### `agent.session.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.started",
"session_id": "ses_abc", "parent_session_id": null,
"properties": {
"provider": "openai",
"model": "gpt-5.4"
}
}
```
Object-lifecycle event. `session_id` and `parent_session_id` are envelope fields. `properties.provider` and `properties.model` are optional.
### `agent.session.activated`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.activated",
"node_id": "code", "node_label": "code", "stage_id": "code@1",
"session_id": "ses_abc",
"properties": {
"thread_id": "main",
"provider": "openai",
"model": "gpt-5.4",
"capabilities": ["steer"],
"visit": 1
}
}
```
Stage-scoped lease event. A stage is steerable while the latest matching `agent.session.activated` lease is active.
### `agent.session.deactivated`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.deactivated",
"node_id": "code", "node_label": "code", "stage_id": "code@1",
"session_id": "ses_abc",
"properties": { "visit": 1 }
}
```
Stage-scoped lease event. Consumers should pair it by `stage_id` and `session_id` so stale deactivations cannot clear a newer active lease.
### `agent.session.ended`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.ended",
"session_id": "ses_abc",
"properties": {}
}
```
Object-lifecycle event. `session_id` and `parent_session_id` are envelope fields. No properties.
### `agent.processing.end`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.processing.end",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {}
}
```
No properties. One per prompt, when the agent has nothing more to do for
it. Pebble's `SessionProjection`, embedded in `StageProjection.agent`, reads
it to mark the prompt complete and the session idle, so the projection
rebuilt from the run's log needs it. Runs recorded before fabro stored it
never have it; their `agent.activity` stays `running`, and
`StageProjection.state` is the authority on whether the stage is done.
### `agent.input`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.input",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"text": "Fix the login bug in auth.rs"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `text` | string | User input text |
### `agent.llm.started`
An inference request is about to be dispatched for this round. Emitted once
per round, after the request is built and compaction has run, immediately
before the stream is opened.
`requested_model` is the canonical requested target, including an optional
speed tier. Failover can re-target mid-stage, so `agent.message` remains
authoritative for what actually answered. No usage or cost fields: neither
exists yet at this point.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.llm.started",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"requested_model": {
"provider": "anthropic",
"model_id": "claude-fable-5",
"speed": "fast"
},
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `requested_model` | object | Requested provider, model ID, and optional speed tier |
| `visit` | number | Graph visit |
### `agent.llm.first_output`
The provider produced its first output for the current attempt. Edge-triggered
once per stream attempt; the latch re-arms when a broken or finish-less stream
replays the turn.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.llm.first_output",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"kind": "reasoning",
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `kind` | string | `reasoning`, `text`, or `tool_call` — observed, not inferred |
| `visit` | number | Graph visit |
### `agent.message`
Emitted when the assistant produces a complete message.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.message",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"text": "I've fixed the bug in auth.rs by...",
"model": "claude-sonnet-4-20250514",
"usage": {
"input_tokens": 3000,
"output_tokens": 1500,
"total_tokens": 4500,
"reasoning_tokens": 200,
"cache_read_tokens": 1000,
"cache_write_tokens": 500
},
"tool_call_count": 2
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `text` | string | Assistant message text |
| `model` | string | Model identifier |
| `usage` | object | Token usage for this message |
| `usage.input_tokens` | number | Input tokens |
| `usage.output_tokens` | number | Output tokens |
| `usage.total_tokens` | number | Total tokens |
| `usage.reasoning_tokens` | number? | Reasoning tokens |
| `usage.cache_read_tokens` | number? | Cache read tokens |
| `usage.cache_write_tokens` | number? | Cache write tokens |
| `usage.speed` | string? | Speed tier |
| `usage.raw` | object? | Raw provider-specific usage |
| `tool_call_count` | number | Number of tool calls in this turn |
### `agent.tool.started`
Emitted when the agent begins a tool call.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.tool.started",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"tool_name": "read_file",
"tool_call_id": "call_abc123",
"arguments": {"path": "src/auth.rs"}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `tool_name` | string | Tool name |
| `tool_call_id` | string | Unique tool call id |
| `arguments` | object | Tool call arguments |
### `agent.tool.completed`
Emitted when a tool call finishes.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.tool.completed",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"tool_name": "read_file",
"tool_call_id": "call_abc123",
"output": "fn login(user: &str) -> Result<Token>...",
"is_error": false
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `tool_name` | string | Tool name |
| `tool_call_id` | string | Unique tool call id |
| `output` | any | Tool output (string or structured) |
| `is_error` | boolean | Whether the tool returned an error |
### `agent.tool.process.completed`
Subordinate diagnostic for a tool call that ran a process, emitted between
`agent.tool.started` and `agent.tool.completed`. It explains the underlying
process outcome; `agent.tool.completed.is_error` remains the protocol and UI
truth. Absent when the tool never produced a process result (setup, transport,
or launch failure) and when the tool ran without a session-bound emitter.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.tool.process.completed",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"tool_call_id": "call_abc123",
"properties": {
"exit_code": 7,
"termination": "exited",
"duration_ms": 812,
"streams_separated": true,
"exec_output_tail": {"stdout": "...", "stderr": "..."},
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `exit_code` | integer | Process exit code; omitted for timeout and cancellation |
| `termination` | string | `exited`, `timed_out`, or `cancelled` |
| `duration_ms` | integer | Process duration |
| `streams_separated` | boolean | `false` when the provider could not separate stdout from stderr; the combined output is then in `exec_output_tail.stdout` |
| `exec_output_tail` | object | Bounded, redacted output tails; omitted when both streams were empty |
| `exec_output_tail.stdout` | string | Bounded stdout tail, or combined-output tail when `streams_separated` is `false`; omitted when empty |
| `exec_output_tail.stderr` | string | Bounded stderr tail; omitted when empty |
| `exec_output_tail.stdout_truncated` | boolean | `true` when earlier stdout bytes were omitted; omitted when `false` |
| `exec_output_tail.stderr_truncated` | boolean | `true` when earlier stderr bytes were omitted; omitted when `false` |
| `visit` | integer | Stage visit |
### `agent.error`
Emitted when the agent encounters an error.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.error",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"error": { ... }
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `error` | object | AgentError (serialized) |
### `agent.warning`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.warning",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"kind": "token_limit",
"message": "Approaching context window limit",
"details": {}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `kind` | string | Warning kind |
| `message` | string | Warning message |
| `details` | object | Additional details |
### `agent.loop.detected`
Emitted when the agent detects a tool-use loop.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.loop.detected",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {}
}
```
No properties.
### `agent.steering.injected`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.steering.injected",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"text": "Remember to run tests after changes"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `text` | string | Injected steering text |
### `agent.compaction.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.compaction.started",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"estimated_tokens": 50000,
"context_window_size": 128000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `estimated_tokens` | number | Estimated tokens before compaction |
| `context_window_size` | number | Model context window size |
### `agent.compaction.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.compaction.completed",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"original_turn_count": 40,
"preserved_turn_count": 10,
"summary_token_estimate": 2000,
"tracked_file_count": 5
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `original_turn_count` | number | Turns before compaction |
| `preserved_turn_count` | number | Turns preserved |
| `summary_token_estimate` | number | Token estimate for summary |
| `tracked_file_count` | number | Files being tracked |
### `agent.llm.retry`
Emitted when an attempt fails to open **or sustain** a stream and the turn is
replayed. The finish-less-stream case carries a synthetic `Stream` error and a
zero delay: the turn restarts even though no error was reported.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.llm.retry",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"provider": "anthropic",
"model": "claude-sonnet-4-20250514",
"attempt": 2,
"delay_secs": 1.5,
"phase": "open",
"error": { ... }
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | LLM provider name |
| `model` | string | Model identifier |
| `attempt` | number | Retry attempt number, 0-based within the loop named by `phase` |
| `delay_secs` | number | Delay before retry in seconds |
| `phase` | string? | `open` (stream failed to open) or `consume` (stream broke or ended without a finish event). Absent on events stored before the discriminator existed |
| `error` | object | SdkError (serialized) |
### `agent.sub.spawned`
Emitted when a sub-agent is spawned.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.sub.spawned",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"agent_id": "sub_xyz",
"depth": 1,
"task": "Write unit tests for auth.rs"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `agent_id` | string | Sub-agent identifier |
| `depth` | number | Nesting depth |
| `task` | string | Task description |
### `agent.sub.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.sub.completed",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"agent_id": "sub_xyz",
"depth": 1,
"success": true,
"turns_used": 8
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `agent_id` | string | Sub-agent identifier |
| `depth` | number | Nesting depth |
| `success` | boolean | Whether the sub-agent succeeded |
| `turns_used` | number | Number of turns used |
### `agent.sub.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.sub.failed",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"agent_id": "sub_xyz",
"depth": 1,
"error": { ... }
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `agent_id` | string | Sub-agent identifier |
| `depth` | number | Nesting depth |
| `error` | object | AgentError (serialized) |
### `agent.sub.closed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.sub.closed",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"agent_id": "sub_xyz",
"depth": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `agent_id` | string | Sub-agent identifier |
| `depth` | number | Nesting depth |
### `agent.memory.loaded`
Emitted once per session right after memory discovery, before skills and MCP
initialization. The event is always emitted, even when no memory files are
loaded (in which case `files` is an empty array). Memory file **contents are
deliberately excluded** from the payload to keep the durable event stream free
of project documentation bytes; consumers that need contents must read the
files themselves.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.memory.loaded",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"provider_profile": "anthropic",
"files": [
{
"path": "/repo/AGENTS.md",
"byte_count": 4096,
"loaded_bytes": 4096,
"truncated": false
}
],
"total_loaded_bytes": 4096,
"budget_bytes": 32768,
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider_profile` | string | Active agent profile (`anthropic`, `openai`, `gemini`) |
| `files` | array | Discovered memory files. Empty when no memory was loaded. |
| `files[].path` | string | Absolute path of the memory file in the sandbox |
| `files[].byte_count` | number | Original file size in bytes |
| `files[].loaded_bytes` | number | Bytes actually loaded into the prompt budget |
| `files[].truncated` | boolean | `true` if the file was truncated to fit the budget |
| `total_loaded_bytes` | number | Sum of `files[].loaded_bytes` |
| `budget_bytes` | number | Total memory budget for the session (currently 32 KiB) |
| `visit` | number | Stage visit count |
### `agent.skills.discovered`
Emitted once per session right after skill discovery completes. The event is
always emitted, even when no skills are found (`skills` is an empty array).
Skills are sorted by name. `source_dirs` lists the directories that were
scanned in the configured precedence order.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.skills.discovered",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"provider_profile": "anthropic",
"source_dirs": [
"/home/test/.fabro/skills",
"/repo/.fabro/skills",
"/repo/skills"
],
"skills": [
{ "name": "commit", "description": "Make a commit" }
],
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider_profile` | string | Active agent profile |
| `source_dirs` | array | Directories scanned for `SKILL.md` files (in precedence order) |
| `skills` | array | Discovered skills, sorted by `name`. Each entry is `{ name, description }`. |
| `visit` | number | Stage visit count |
### `agent.skill.activated`
Emitted whenever a skill is activated in the running session. Sources:
- `slash` — the user input matched a `/skill-name` token and the skill template
was expanded inline. This event replaces the previous internal-only
`agent.skill.expanded` notification.
- `tool` — the model successfully called the `use_skill` tool and the skill
template was returned. Failed `use_skill` lookups (unknown names, missing
parameters) do **not** emit this event.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.skill.activated",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"skill_name": "commit",
"source": "slash",
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `skill_name` | string | Name of the activated skill |
| `source` | string | `"slash"` for `/skill-name` expansion, `"tool"` for `use_skill` activations |
| `visit` | number | Stage visit count |
> `agent.skill.expanded` does not exist. The `AgentEvent::SkillExpanded`
> variant this note once described has since been removed from the code
> entirely; slash-skill expansion is reported through `agent.skill.activated`
> with `source == "slash"` instead.
### `prompt.failover`
Emitted by a one-shot prompt stage when it moves to a fallback route. The
prompt stage walks its fallback plan itself, so this is fabro's own event.
An agent stage never emits it: pebble walks the routes and reports each
move as `agent.route.failover`, stored verbatim (below).
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "prompt.failover",
"node_id": "summarize",
"node_label": "summarize",
"properties": {
"from_provider": "anthropic",
"from_model": "claude-sonnet-4-20250514",
"to_provider": "openai",
"to_model": "gpt-4o",
"attempt": 1,
"error": "rate limited"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `from_provider` | string | The provider that failed |
| `from_model` | string | The model that failed |
| `to_provider` | string | The provider the prompt continued on |
| `to_model` | string | The model the prompt continued on |
| `attempt` | number? | How many routes the prompt had moved through, this one included. Absent only on events recorded before it was kept |
| `error` | string | The failure that ended the previous route |
Events recorded before this rename were named `agent.failover` and carried
`original_provider`, `original_model`, `requested_reasoning_effort`,
`effective_reasoning_effort`, and `continuation`; nothing read them.
### `agent.route.failover`, `agent.mcp.server.ready`, `agent.mcp.server.failed`, `agent.mcp.server.disconnected`
Pebble's `RouteFailover`, `McpServerReady`, `McpServerFailed`, and
`McpServerDisconnected` events, stored verbatim with pebble's envelope in
`properties` like every other pebble event. They are the only record of an
agent stage's route moves and MCP server outcomes: the stage view reads
both from `StageProjection.agent`, which they feed. Runs recorded before
fabro stored them carry fabro's former mirrors, `agent.failover`,
`agent.mcp.ready`, `agent.mcp.failed`, and `agent.mcp.disconnected`,
which no reader folds any more.
### `agent.route.failover.stopped`
Pebble's `RouteFailoverStopped` event, stored verbatim like every other
pebble event. An agent stage with fallback routes
publishes it when a model failure ends the prompt on its current route
anyway: the failure does not qualify for failover (`reason: "ineligible"`)
or every route has been taken (`reason: "exhausted"`). It follows the
`agent.error` that reports the failure; a stage without fallback routes and
a cancelled prompt publish nothing here. The properties are pebble's
envelope (`seq`, `stream_id`, `session_id`, `timestamp`) plus
`event.RouteFailoverStopped` with `route`, `attempt`, `reason`, and `error`.
### Agent events that are never serialized
`AgentEvent` also has variants that exist only on the agent session's
in-process broadcast channel. `is_streaming_noise()` filters them out before
the workflow emitter builds a `RunEvent`, so they never reach the run store,
SSE, `fabro events`, or a JSONL sink — they have no envelope, and no external
consumer can observe them:
- `AssistantOutputReplace` — clears in-progress output buffers when a turn is
replayed
- `TextDelta`, `ReasoningDelta` — streaming assistant chunks
- `ToolCallOutputDelta` — streaming tool output chunks
They were previously documented here as though they were durable events, with
full envelope examples. If any of them ever needs to be durable, it belongs in
a separate transient stream rather than the canonical persisted contract —
long autonomous runs would generate orders of magnitude more delta traffic
than the interactive sessions surface handles.
---
## Subgraph events
### `subgraph.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "subgraph.started",
"node_id": "pipeline",
"node_label": "pipeline",
"properties": {
"start_node": "sub_start"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `start_node` | string | First node in the subgraph |
### `subgraph.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "subgraph.completed",
"node_id": "pipeline",
"node_label": "pipeline",
"properties": {
"steps_executed": 4,
"status": "succeeded",
"duration_ms": 25000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `steps_executed` | number | Number of steps executed |
| `status` | string | Subgraph outcome status |
| `duration_ms` | number | Subgraph duration |
---
## Sandbox events
Sandbox events have the nested `SandboxEvent` unwrapped into `properties`.
### `sandbox.initializing`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.initializing",
"properties": {
"provider": "daytona"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | Sandbox provider name |
### `sandbox.ready`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.ready",
"properties": {
"provider": "daytona",
"duration_ms": 5000,
"name": "sandbox-01JQXYZ",
"cpu": 4.0,
"memory": 8.0,
"url": "https://sandbox.example.com"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | Sandbox provider name |
| `duration_ms` | number | Initialization duration |
| `name` | string? | Sandbox instance name |
| `cpu` | number? | CPU cores allocated |
| `memory` | number? | Memory in GB allocated |
| `url` | string? | Sandbox URL |
### `sandbox.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.failed",
"properties": {
"provider": "daytona",
"error": "workspace creation failed",
"duration_ms": 3000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | Sandbox provider name |
| `error` | string | Error message |
| `duration_ms` | number | Time before failure |
### `sandbox.initialized`
Emitted after the engine completes sandbox initialization (distinct from `sandbox.ready` which comes from the sandbox provider).
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.initialized",
"properties": {
"working_directory": "/workspace/my-project",
"provider": "daytona",
"identifier": "sandbox-123",
"repo_cloned": true,
"clone_origin_url": "https://github.com/acme/my-project.git",
"clone_branch": "main"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `working_directory` | string | Working directory inside sandbox |
| `provider` | string | Sandbox provider |
| `identifier` | string? | Provider-specific sandbox identifier |
| `repo_cloned` | boolean? | Whether the provider cloned a repository into the sandbox |
| `clone_origin_url` | string? | Repository URL cloned into the sandbox, with credentials removed |
| `clone_branch` | string? | Branch requested for the sandbox clone |
### `sandbox.cleanup.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.cleanup.started",
"properties": {
"provider": "daytona"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | Sandbox provider name |
### `sandbox.cleanup.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.cleanup.completed",
"properties": {
"provider": "daytona",
"duration_ms": 2000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | Sandbox provider name |
| `duration_ms` | number | Cleanup duration |
### `sandbox.cleanup.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.cleanup.failed",
"properties": {
"provider": "daytona",
"error": "workspace not found"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `provider` | string | Sandbox provider name |
| `error` | string | Error message |
### Sandbox driver events
Everything the sandbox driver reports about a run's sandbox is stored whole. The
event name derives from the driver's event: `<subject>.<action>.<phase>` for an
operation (`sandbox.start.started`, `sandbox.stop.completed`, `sandbox.delete.failed`,
`sandbox.create.progress` for an image pull inside the create, `snapshot.create.started`
and `snapshot.create.completed` for a snapshot build), `<subject>.state` for a state
observation, and `<subject>.notice` for a notice. `properties` is the driver's event as
the driver serializes it.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.stop.completed",
"properties": {
"id": {"source_id": "9b2f…", "sequence": 4},
"occurred_at": "2026-08-31T20:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox", "id": "container-abc123"},
"operation_id": "58a1…",
"correlation_id": "01JQ…",
"type": "operation_completed",
"action": "stop",
"duration": {"secs": 1, "nanos": 250000000}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `id` | object | The driver's event id: `source_id` and `sequence` within that source |
| `occurred_at` | string | When the driver observed the event (RFC 3339) |
| `provider` | string | The driver's provider kind (`host`, `docker`, `daytona`, a plugin's kind) |
| `subject` | object | `type` (`sandbox`, `snapshot`, `volume`, `provider`) with the resource's `id` and `name` when known |
| `operation_id` | string | Groups the started, progress, and completed or failed events of one operation |
| `correlation_id` | string | The run id fabro attached |
| `type` | string | `operation_started`, `operation_progress`, `operation_completed`, `operation_failed`, `state_observed`, or `notice` |
| `action` | string | The operation (`create`, `start`, `stop`, `delete`, `snapshot`, …) on operation events |
| `progress` | object | `code` (`image.pull`, `snapshot.build`, …), `message`, and optional `completed`, `total`, `unit` on progress events |
| `duration` | object | `secs` and `nanos` on completed and failed events |
| `error` | object | `kind`, `message`, `retryable`, `causes` on failed events |
Events stored under `sandbox.start.*`, `sandbox.stop.*`, `sandbox.delete.*`, and
`sandbox.snapshot.*` before the driver's events were kept whole carry fabro's earlier
`provider`, `name`, `duration_ms`, and `error` properties instead; readers treat them as
unknown bodies.
### `sandbox.git.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.git.started",
"properties": {
"url": "https://github.com/org/repo.git",
"branch": "main"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `url` | string | Repository URL |
| `branch` | string? | Branch to clone |
### `sandbox.git.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.git.completed",
"properties": {
"url": "https://github.com/org/repo.git",
"duration_ms": 8000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `url` | string | Repository URL |
| `duration_ms` | number | Clone duration |
### `sandbox.git.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.git.failed",
"properties": {
"url": "https://github.com/org/repo.git",
"error": "authentication failed"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `url` | string | Repository URL |
| `error` | string | Error message |
---
## Setup events
### `setup.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "setup.started",
"properties": {
"command_count": 3
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `command_count` | number | Number of setup commands |
### `setup.command.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "setup.command.started",
"properties": {
"command": "npm install",
"index": 0
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `command` | string | Command being run |
| `index` | number | Command index |
### `setup.command.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "setup.command.completed",
"properties": {
"command": "npm install",
"index": 0,
"exit_code": 0,
"duration_ms": 5000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `command` | string | Command that ran |
| `index` | number | Command index |
| `exit_code` | number | Process exit code |
| `duration_ms` | number | Command duration |
### `setup.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "setup.completed",
"properties": {
"duration_ms": 15000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `duration_ms` | number | Total setup duration |
### `setup.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "setup.failed",
"properties": {
"command": "npm install",
"index": 1,
"exit_code": 1,
"stderr": "npm ERR! ..."
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `command` | string | Command that failed |
| `index` | number | Command index |
| `exit_code` | number | Process exit code |
| `stderr` | string | Standard error output |
---
## CLI ensure events
These legacy events may appear in older run logs. Current CLI backend runs do not emit them because Fabro no longer installs or prepares provider CLIs at stage runtime.
### `cli.ensure.started`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "cli.ensure.started",
"properties": {
"cli_name": "aider",
"provider": "openai"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `cli_name` | string | CLI tool name |
| `provider` | string | LLM provider |
### `cli.ensure.completed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "cli.ensure.completed",
"properties": {
"cli_name": "aider",
"provider": "openai",
"already_installed": true,
"node_installed": false,
"duration_ms": 500
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `cli_name` | string | CLI tool name |
| `provider` | string | LLM provider |
| `already_installed` | boolean | Whether it was already present |
| `node_installed` | boolean | Whether Node.js was installed |
| `duration_ms` | number | Duration |
### `cli.ensure.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "cli.ensure.failed",
"properties": {
"cli_name": "aider",
"provider": "openai",
"error": "pip install failed",
"duration_ms": 3000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `cli_name` | string | CLI tool name |
| `provider` | string | LLM provider |
| `error` | string | Error message |
| `duration_ms` | number | Duration |
---
## Pull request events
### `pull_request.creation_requested`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.creation_requested",
"properties": {
"creation_id": "01KYYK70WTZT2E551P3H5P0059",
"model": "gpt-5.4",
"force": false
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `creation_id` | string | Stable identifier for this pull request creation request |
| `model` | string | Resolved model identifier used to generate the pull request content |
| `force` | boolean | Whether creation is allowed for a run without a successful conclusion |
### `pull_request.created`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.created",
"properties": {
"pr_url": "https://github.com/org/repo/pull/42",
"pr_number": 42,
"head_sha": "d34db33f",
"draft": true
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `pr_url` | string | Pull request URL |
| `pr_number` | number | Pull request number |
| `head_sha` | string (optional) | Verified commit SHA at the remote PR head; absent on older events |
| `draft` | boolean | Whether the PR is a draft |
### `pull_request.linked`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.linked",
"properties": {
"pull_request": {
"provider": "github",
"html_url": "https://github.com/org/repo/pull/42",
"number": 42,
"owner": "org",
"repo": "repo",
"title": "Review deployment chart"
}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `pull_request` | object | Stored GitHub pull request association. `title`, `base_branch`, and `head_branch` may be included when live GitHub metadata is available. |
### `pull_request.unlinked`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.unlinked",
"properties": {
"pull_request": {
"provider": "github",
"html_url": "https://github.com/org/repo/pull/42",
"number": 42
}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `pull_request` | object | Pull request association removed from the run. |
### `pull_request.failed`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.failed",
"properties": {
"creation_id": "01KYYK70WTZT2E551P3H5P0059",
"error": "insufficient permissions"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `creation_id` | string (optional) | Explicit pull request creation this failure resolves. Absent for publish-stage failures. |
| `error` | string | Error message |
When `creation_id` names the run's pending pull request creation, the run
projection marks that creation `failed`. A `pull_request.failed` event without
a `creation_id` (the workflow publish stage) does not change creation state.
## Artifact events
### `artifact.captured`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "artifact.captured",
"node_id": "code",
"node_label": "code",
"properties": {
"attempt": 1,
"node_slug": "code",
"path": "screenshot.png",
"mime": "image/png",
"content_md5": "d41d8cd98f00b204e9800998ecf8427e",
"content_sha256": "e3b0c44298fc1c149afbf4c8996fb924...",
"bytes": 45000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `attempt` | number | Attempt number |
| `node_slug` | string | Node slug for asset path |
| `path` | string | Asset file path |
| `mime` | string | MIME type |
| `content_md5` | string | MD5 hash |
| `content_sha256` | string | SHA-256 hash |
| `bytes` | number | File size in bytes |
---
## SSH events
### `ssh.ready`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "ssh.ready",
"properties": {
"ssh_command": "ssh user@host -p 2222"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `ssh_command` | string | SSH command to connect |
---
## Watchdog events
### `watchdog.timeout`
Emitted when the stall watchdog detects no progress.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "watchdog.timeout",
"node_id": "code",
"node_label": "code",
"properties": {
"idle_seconds": 1800
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `idle_seconds` | number | Seconds since last activity |