Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Tool events

Observe tool calls, results, preparation, and execution boundaries.

developer

Tool events expose the boundary between a model Step and a tool execution. The durable values describe approval requests and user-input requests; the live Ephemeral values describe the start and finish of an approved call. The authoritative Step record remains StepDone, which contains the finalized AI message and tool results.

Event shapes

type PermissionDecisionEffect string
const (
	PermissionEffectApprove PermissionDecisionEffect = "approve"
	PermissionEffectDeny    PermissionDecisionEffect = "deny"
)

type PermissionRequested struct {
	enduring
	loopScoped
	Header
	ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
	Request tool.Request `json:"-"`
}

type PermissionDecided struct {
	enduring
	loopScoped
	Header
	ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
	Effect PermissionDecisionEffect `json:"effect,omitempty"`
	Reason string `json:"reason,omitempty"`
	Subject string `json:"subject,omitempty"`
	Audit string `json:"audit,omitempty"`
}

type UserInputRequested struct {
	enduring
	loopScoped
	Header
	ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
	Question string `json:"question,omitempty"`
	Choices []string `json:"choices,omitempty"`
}

type ToolCallStarted struct {
	ephemeral
	loopScoped
	Header
	ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
	ToolName string `json:"tool_name,omitempty"`
	Summary string `json:"summary,omitempty"`
}

type ToolCallCompleted struct {
	ephemeral
	loopScoped
	Header
	ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
	IsError bool `json:"is_error,omitzero"`
	ResultPreview string `json:"result_preview,omitempty"`
}

All five values are loop-scoped and require the full Session, Loop, Turn, and Step coordinate quartet. Each also requires a non-zero ToolExecutionID, the stable join key for one call across approval, execution, and result displays.

EventClassDurablePayload purpose
PermissionRequestedEnduringYesTyped prepared request for an interactive approval
PermissionDecidedEnduringYesRedacted approve/deny outcome and audit summary
UserInputRequestedEnduringYesQuestion and choices for a tool that needs free-form input
ToolCallStartedEphemeralNoCapped name and summary when execution begins
ToolCallCompletedEphemeralNoCapped result preview and error bit

PermissionRequested is not a gate-open event. A gated permission is rendered by GateOpened and closed by GateResolved; this event is the typed prepared request delivered on the loop’s per-turn stream. PermissionDecided excludes the ask effect because a gated ask is represented by the gate lifecycle.

Approval and execution sequence

%%{init: {"theme":"dark"}}%%
sequenceDiagram
    participant L as Loop actor
    participant H as Event hub
    participant App as Subscriber
    participant T as Tool

    L->>H: PermissionRequested (Enduring)
    H-->>App: durable Delivery
    App->>L: answer gate or policy decision
    L->>H: PermissionDecided (Enduring)
    alt approved
        L->>H: ToolCallStarted (Ephemeral)
        L->>T: execute approved call
        T-->>L: result
        L->>H: ToolCallCompleted (Ephemeral)
        L->>H: StepDone (Enduring) with finalized result message
    else denied
        L->>H: PermissionDecided{Effect: deny}
        L->>H: StepDone or terminal turn outcome, according to loop execution
    end

Only the Enduring values have journal sequences. A reconnecting UI may miss the start or completion preview, then rebuild the durable state from PermissionRequested, PermissionDecided, and StepDone. It must not infer that a tool is still running merely because no Ephemeral completion arrived.

Safety and validation

The durable request wire uses tool.ValidateRequest and a strict request decoder. It carries the typed requirement and candidate descriptions, never grant tokens or raw tool arguments. PermissionDecided.Subject and Audit are summaries. ToolCallStarted.Summary and ToolCallCompleted.ResultPreview are capped at construction and are for presentation, not for replaying the call.

event.ValidateEvent rejects any of these events with missing ToolExecutionID, missing step coordinates, invalid visibility, or malformed body fields. MarshalEvent returns *event.EventEncodeError when the typed request codec rejects a request. A malformed restored request fails closed; a consumer must not fall back to untyped JSON or execute it.

Observe tool activity

func watchTools(ctx context.Context, live session.Session, loopID uuid.UUID) error {
	sub, err := live.SubscribeEvents(event.EventFilter{
		Ephemeral: event.LoopScope{
			Loops: map[uuid.UUID]struct{}{loopID: struct{}{}},
		},
		Enduring: event.LoopScope{
			Loops: map[uuid.UUID]struct{}{loopID: struct{}{}},
		},
	})
	if err != nil {
		return err
	}
	defer sub.Close()

	for delivery := range sub.Events() {
		switch e := delivery.Event.(type) {
		case event.PermissionRequested:
			log.Printf("approval requested for %s: %v", e.ToolExecutionID, e.Request)
		case event.PermissionDecided:
			log.Printf("tool %s decision %s at journal %d", e.ToolExecutionID, e.Effect, delivery.JournalSeq)
		case event.UserInputRequested:
			log.Printf("input requested for %s: %s (%d choices)", e.ToolExecutionID, e.Question, len(e.Choices))
		case event.ToolCallStarted:
			log.Printf("tool %s started: %s", e.ToolExecutionID, e.ToolName)
		case event.ToolCallCompleted:
			log.Printf("tool %s completed, error=%t: %s", e.ToolExecutionID, e.IsError, e.ResultPreview)
		}
	}
	if err := sub.Err(); err != nil {
		return err
	}
	return nil
}

The loop ID is part of the event header, while the tool execution ID is part of the event body. Use both when a UI has more than one loop or when tool IDs may be generated by different loop actors.

Source and proofs

For the approval boundary and the public answer path, continue to gate and review events. For the enclosing model work, see turn and Step events.

← back to documentation