---
title: "record_signal: record WHAT HAPPENED in reality. actual_signal is written once and is immutable — a second call returns 409. The action moves to needs_decision; it is not resolved unless resolve: true is passed explicitly. Interpretation is a separate command (interpret-signal) and closing is another (close-loop)."
method: POST
path: "/signals"
---

# record_signal: record WHAT HAPPENED in reality. actual_signal is written once and is immutable — a second call returns 409. The action moves to needs_decision; it is not resolved unless resolve: true is passed explicitly. Interpretation is a separate command (interpret-signal) and closing is another (close-loop).

`POST /signals`

## Request

- Base URL: `https://forbiz.io/api/public/agent`
- URL: `https://forbiz.io/api/public/agent/signals`
- Auth: HTTP bearer

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `actionId` string, required
  - `actualSignal` string, required
  - `outcomeStatus` 'expected' | 'partial' | 'unexpected' | 'deferred'
  - `learningNote` string
  - `resolve` boolean: Optional shortcut that also resolves the loop. Prefer the explicit close-loop command.

## Response `200`

Signal recorded; action is now needs_decision

- object
  - `ok` boolean
  - `owner` Owner: Ownership of the record. Never means the performer of an operation — use /actions/{id}/history for actor semantics.
    - `type` 'human'
    - `user_id` string: Owning Forbiz user. Action payloads never repeat raw ownership UUIDs.
    - `workspace_id` string, nullable
    - `note` string
  - `action` Action: An action. Internal ownership UUIDs are never included; ownership is reported once in `owner`.
    - `id` string
    - `description` string
    - `verb` string
    - `action_type` string
    - `lifecycle_status` 'planned' | 'executed' | 'waiting_for_signal' | 'signal_recorded' | 'resolved' | 'closed_without_signal' | 'cancelled': READ-ONLY as reported. 'signal_recorded' is DERIVED (1.55.0): an action that has actual_signal is never reported as waiting_for_signal, because it is waiting for interpretation, not for a signal. lifecycle_status_stored carries the stored value.
    - `lifecycle_status_stored` 'planned' | 'executed' | 'waiting_for_signal' | 'resolved' | 'closed_without_signal' | 'cancelled', nullable: The stored column, unchanged. Write paths and filters use these values.
    - `status` string
    - `why_this_move` string, nullable
    - `expected_signal` string, nullable
    - `expected_signal_at` string, date-time, nullable
    - `expected_signal_source` 'human' | 'external' | 'null', nullable: LEGACY: AUDIENCE ONLY ('human' | 'external'). It is NOT the declared signal source and is null unless an audience was sent. What was declared as expectedSignalSource {kind, detail} is echoed as expected_signal_source_declared {kind, detail, audience} and stored in expected_signal_source_kind / expected_signal_source_detail.
    - `expected_signal_source_declared` object, nullable: What the creator declared about WHERE the signal will appear (1.55.0). kind/detail come from the stored declaration columns; audience is the legacy expected_signal_source value.
      - `kind` string, nullable
      - `detail` string, nullable
      - `audience` 'human' | 'external' | 'null', nullable
    - `human_intervention_kind` 'decide' | 'execute' | 'null', nullable: Caller-declared kind of human intervention this action needs. 'decide' = a human decision; 'execute' = a human must perform it. Never inferred from action_type. A STRATEGY CHECKPOINT is exactly human_intervention_kind='decide' AND action_type='strategy_checkpoint'; a generic 'decide' action gates nothing. Strategy approval can NEVER be written through this API: only the authenticated human UI or an explicit correlated Telegram Approve button records it.
    - `actual_signal` string, nullable: What happened in reality. Written once; never overwritten.
    - `actual_signal_at` string, date-time, nullable
    - `outcome_status` 'expected' | 'partial' | 'unexpected' | 'deferred' | 'null', nullable
    - `learning_note` string, nullable
    - `executed_at` string, date-time, nullable: When the action actually happened in the real world (may be backdated by the caller).
    - `execution_recorded_at` string, date-time, nullable: When Forbiz recorded the execution. Server-owned; a caller can never supply it.
    - `parent_action_id` string, nullable
    - `root_action_id` string, nullable
    - `outreach_id` string, nullable
    - `person_id` string, nullable
    - `organization_id` string, nullable
    - `campaign_target_id` string, nullable
    - `timing` Timing: ONE canonical derivation, computed at request time and never stored. Overdue is a single concept with two governing clocks: before execution the COMMITMENT clock (due_at) governs, after execution the REVIEW clock (expected_signal_at) governs. There is no grace period, and is_commitment_overdue can never be true while is_overdue is false. timing.due_at is the commitment clock ONLY — expected_signal_at never populates it (use timing.review_due_at).
      - `temporal_state` 'planned' | 'needs_timing' | 'waiting' | 'overdue' | 'needs_decision' | 'resolved' | 'closed_without_signal' | 'cancelled'
      - `due_at` string, date-time, nullable: The COMMITMENT clock (actions.due_at).
      - `review_due_at` string, date-time, nullable: The REVIEW clock (expected_signal_at).
      - `governing_clock` 'commitment' | 'review' | 'null', nullable
      - `governing_due_at` string, date-time, nullable: The instant of the governing clock — sort attention by this.
      - `executed_at` string, date-time, nullable: Real-world occurrence time. The ONLY timestamp temporal math uses.
      - `recorded_at` string, date-time, nullable: When Forbiz recorded the execution. Informational; never used for timing math.
      - `is_backdated_execution` boolean: True when the execution was recorded more than 5 minutes after it happened (reconciled history).
      - `evaluated_at` string, date-time
      - `seconds_until_due` integer, nullable
      - `overdue_by_seconds` integer, nullable
      - `is_overdue` boolean
      - `requires_feedback` boolean
      - `requires_decision` boolean
      - `available_operations` string[]
      - `available_commands` OperationCommand[]
        - `operation` 'mark_executed' | 'record_signal' | 'postpone' | 'set_review_date' | 'interpret_signal' | 'create_next_action' | 'close_loop' | 'close_without_signal'
        - `method` 'POST'
        - `path` string: Path relative to the API server, with {id} substituted by the action id.
        - `body` string[]: Required request body fields.
        - `resulting_state` string
        - `description` string
  - `action_id` string

## Other responses

- `208`: Idempotent replay of a previous request with the same Idempotency-Key. Nothing was applied again.
- `404`: Not found
- `409`: A signal is already recorded (immutable) or the loop is closed

---

[API](https://skmtc.dev/forbiz/apis/forbiz-external-agent-api.md) · [All operations](https://skmtc.dev/forbiz/apis/forbiz-external-agent-api/llms.txt) · [OpenAPI document](https://skmtc.dev/forbiz/apis/forbiz-external-agent-api/revisions/e5b10d59dc5f?raw)
