# 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`

Emitted when Fabro begins a durable metadata snapshot operation. These are product events for Fabro metadata snapshots, not tracing spans for the underlying git or filesystem work.

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`

Emitted when Fabro commits and pushes a metadata snapshot successfully.

```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`

Emitted when a real metadata snapshot attempt fails. It is emitted 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 |
| `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 |
| `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 |

### `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

Most agent activity events are stage-scoped and carry `node_id` (the workflow stage), `node_label`, `stage_id`, `session_id`, and `parent_session_id` in the envelope. Session object lifecycle events are the exception: `agent.session.started` and `agent.session.ended` are not stage-scoped and intentionally omit `node_id`, `node_label`, `stage_id`, and `visit`.

### `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.

### `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.mcp.ready`

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "agent.mcp.ready",
  "node_id": "code", "node_label": "code",
  "session_id": "ses_abc",
  "properties": {
    "server_name": "github",
    "tool_count": 2,
    "tools": [
      {
        "name": "mcp__github__create_issue",
        "original_name": "create_issue"
      },
      {
        "name": "mcp__github__list_issues",
        "original_name": "list_issues"
      }
    ],
    "visit": 1
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `server_name` | string | MCP server name |
| `tool_count` | number | Number of tools available |
| `tools` | array | Names-only tool summaries for the ready server, sorted by qualified `name`. Each entry has `name` (Fabro-qualified `mcp__{server}__{tool}` identifier) and `original_name` (server-provided tool name). Descriptions and input schemas are intentionally omitted. The field is omitted from serialized JSON for legacy parity when empty. |
| `visit` | number | Stage visit count when the server became ready |

### `agent.mcp.failed`

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "agent.mcp.failed",
  "node_id": "code", "node_label": "code",
  "session_id": "ses_abc",
  "properties": {
    "server_name": "filesystem",
    "error": "Connection refused"
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `server_name` | string | MCP server name |
| `error` | string | Error message |

### `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.

### `agent.failover`

Emitted when the agent fails over to a different LLM provider/model.

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "agent.failover",
  "node_id": "code",
  "node_label": "code",
  "properties": {
    "from_provider": "anthropic",
    "from_model": "claude-sonnet-4-20250514",
    "to_provider": "openai",
    "to_model": "gpt-4o",
    "error": "rate limited"
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `from_provider` | string | Original provider |
| `from_model` | string | Original model |
| `to_provider` | string | Failover provider |
| `to_model` | string | Failover model |
| `error` | string | Error that triggered failover |

### 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.snapshot.pulling`

Emitted only when the Docker image cache misses and Fabro starts pulling the image.

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "sandbox.snapshot.pulling",
  "properties": {
    "name": "my-image:latest"
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Image/snapshot name |

### `sandbox.snapshot.creating`

Emitted only when a Daytona snapshot cache miss or inactive snapshot requires Fabro to create or wait for the snapshot.

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "sandbox.snapshot.creating",
  "properties": {
    "name": "my-snapshot"
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |

### `sandbox.snapshot.ready`

Emitted when an image or snapshot ensure step succeeds. Cache hits still emit this event with a near-zero `duration_ms`; explicit no-op paths such as Docker `auto_pull = false` and the Daytona default snapshot path do not.

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "sandbox.snapshot.ready",
  "properties": {
    "name": "my-snapshot",
    "duration_ms": 30000
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
| `duration_ms` | number | Ensure duration |

### `sandbox.snapshot.failed`

Emitted when an image or snapshot ensure step fails.

```json
{
  "id": "...", "ts": "...", "run_id": "...",
  "event": "sandbox.snapshot.failed",
  "properties": {
    "name": "my-snapshot",
    "error": "disk quota exceeded"
  }
}
```

| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
| `error` | string | Error message |
| `causes` | string[] | Optional error cause chain |

### `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 |
