Steps

A Step is the atomic unit of an opentine run. Every thought, tool call, model invocation, success, or failure is captured as an immutable, content-addressed Step in the run tree.

Step Fields

Step is a msgspec.Struct with frozen=True. It has the following fields:

  • id (str) — content-addressed identifier, the first 12 characters of a SHA-256 hash
  • parent_id (str | None) — ID of the parent step, or None for root steps
  • kind (StepKind) — what type of step this is (see below)
  • inputs (dict) — the data that went into this step (prompt, tool arguments, thought text, etc.)
  • outputs (dict) — the data that came out (completion text, tool results, error messages, etc.)
  • model_info (str) — which model was used for this step, if applicable
  • timestamp (float) — Unix timestamp of when the step was created
  • duration (float) — wall-clock time in seconds the step took to execute
  • cost (float) — monetary cost of the step in USD (e.g., API call cost)
step_fields.py
1from opentine import Step
2
3# A Step is a frozen msgspec.Struct — all fields are read-only after creation
4step = Step(
5    id="a1b2c3d4e5f6",
6    parent_id=None,
7    kind="think",
8    inputs={"thought": "I should search for recent papers."},
9    outputs={},
10    model_info="claude-sonnet-4-20250514",
11    timestamp=1717200000.0,
12    duration=0.0,
13    cost=0.0,
14)

StepKind Enum

The StepKind enum defines five kinds of steps, each representing a different phase of agent execution:

  • think— internal reasoning. The agent's chain-of-thought before deciding what to do next. Inputs typically contain a thought key.
  • tool— a tool invocation. Inputs contain the tool name and arguments; outputs contain the tool's return value.
  • model — a call to the underlying LLM. Inputs contain the prompt; outputs contain the completion. This is where cost and duration are usually highest.
  • done — successful completion. The agent has finished its task. Outputs contain the final result.
  • error — failure. Something went wrong. Outputs contain the error message and any diagnostic info.
step_kinds.py
from opentine import StepKind

# The five step kinds
StepKind.think   # Agent reasoning / chain-of-thought
StepKind.tool    # Tool invocation (search, fetch, code exec, etc.)
StepKind.model   # LLM call — prompt in, completion out
StepKind.done    # Successful terminal step
StepKind.error   # Failure terminal step

Content Addressing

Every step's ID is generated by the step_id() function, which computes a SHA-256 hash of three inputs: the step's kind, its inputs dict, and its parent_id. The resulting ID is the first 12 hex characters of the digest.

This means identical steps always get the same ID. If two runs perform the same tool call with the same arguments from the same parent, they produce the same step ID. This is the foundation of opentine's deduplication and caching — when you fork a run, steps that haven't changed keep their original IDs.

content_addressing.py
1from opentine import step_id
2
3# step_id hashes kind + inputs + parent_id with SHA-256
4sid = step_id(
5    kind="tool",
6    inputs={"tool": "search", "query": "RLHF papers"},
7    parent_id="a1b2c3d4e5f6",
8)
9# => "e7f8a9b0c1d2"  (first 12 hex chars of the SHA-256 digest)
10
11# Same inputs always produce the same ID
12sid2 = step_id(
13    kind="tool",
14    inputs={"tool": "search", "query": "RLHF papers"},
15    parent_id="a1b2c3d4e5f6",
16)
17assert sid == sid2  # deterministic
18
19# Different inputs produce a different ID
20sid3 = step_id(
21    kind="tool",
22    inputs={"tool": "search", "query": "different query"},
23    parent_id="a1b2c3d4e5f6",
24)
25assert sid != sid3

Note that outputs, duration, and cost are notpart of the hash. A step's identity is defined by what it intends to do (kind + inputs + position in tree), not by what it produced. This is intentional: it means you can re-execute a step and get different outputs while maintaining the same structural position in the tree.

Immutability

Steps are frozen structs. Once created, no field can be modified. This guarantee is critical for content addressing — if a step's inputs could change after creation, its ID would become stale. Immutability also makes runs safe to share across threads and processes.

immutability.py
1# Steps are frozen — you cannot modify them after creation
2step = run.add_step(kind="think", inputs={"thought": "hello"})
3
4step.outputs = {"new": "data"}
5# => AttributeError: frozen struct 'Step' does not support attribute assignment
6
7# To "change" a step, add a new child step instead
8corrected = run.add_step(
9    kind="think",
10    inputs={"thought": "Actually, let me reconsider..."},
11    parent_id=step.id,
12)

Creating Steps

You almost never construct a Step directly. Instead, use Run.add_step(), which calls step_id()internally, sets the timestamp, and appends the step to the run's step list.

add_step.py
1# You rarely construct Step directly — use Run.add_step() instead
2step = run.add_step(
3    kind="tool",
4    inputs={"tool": "fetch", "url": "https://example.com"},
5    outputs={"html": "<html>...</html>"},
6    parent_id=parent_step.id,
7    duration=0.8,
8    cost=0.0,
9)
10
11# add_step() calls step_id() internally to generate the content-addressed ID
12print(step.id)        # => "f3a7c9e12b04"
13print(step.kind)      # => "tool"
14print(step.parent_id) # => parent_step.id