Files
api/services/nginx/app/modules/bird/bird.md
T
Jeppe B 4587bdfb06 Restore API startup by isolating Bird activation (#334)
Keep Bird activation outside the PHP-FPM/nginx startup path so Bird configuration failures cannot make the core API unavailable. Retain guarded explicit activation and regression coverage.
2026-07-29 22:41:19 +02:00

13 KiB

Bird Module

This module provides Bird API integration for voice calls and number management.

Control Plane integration

The Bird module is the credential and public-webhook authority for Pleno Control Plane. Control Plane must never receive the Bird access key. The integration is disabled by default and has three independently protected surfaces:

  • GET /bird/health is an end-user-permission-protected, read-only replacement for the outbound-call connection test.
  • /bird/control-plane/v1/* accepts only the configured control_plane_token bearer token and exposes explicitly listed read operations. It is not a generic Bird proxy.
  • POST /bird/webhooks/notifications accepts only Bird notifications signed against the exact configured public HTTPS URL. It checks the replay window and durably deduplicates both Bird request ID and signature before returning success.
  • POST /bird/flows/evaluate accepts a timestamp-bound HMAC in x-pleno-flow-timestamp and x-pleno-flow-signature. It returns only deterministic tag, assign, snooze, or close decisions from a valid versioned policy.

Required configuration is:

  • workspaceId: canonical workspace identifier. While migrating, an empty value falls back to legacy workplaceId.
  • allowed_channel_ids_json: canonical JSON array of explicitly approved channel IDs. Its empty default falls back to legacy channelId; malformed or non-empty invalid configuration fails closed.
  • control_plane_enabled=false and secret control_plane_token.
  • secret webhook_signing_key, exact webhook_public_url, and webhook_replay_window_seconds=300.
  • flow_enabled=false, secret flow_shared_secret, and a valid flow_policy_json document.
  • operations_actions_enabled=false for confirmed typed voice actions.
  • outbound_messages_enabled=false, participantId, and an immutable template_policy_json allowlist for confirmed conversation replies.

The gateway intentionally does not expose conversation creation, number deletion, physical gate actions, arbitrary recipients, attachments, or a generic proxy. Outbound references are durably reserved before a provider request; an ambiguous outcome must be inspected through /bird/control-plane/v1/messages/by-reference and is never blindly retried. Credentials and write switches can only be changed by the guarded CLI activation command, never by a web request.

Schema deployment and preflight

The durable webhook and outbound-action ledgers use checked-in schema version 1. Schema changes are never run from a web request, worker, or core API startup. Use these commands for deployment preflight and recovery:

php scripts/bird-control-plane-schema.php check
php scripts/bird-control-plane-schema.php apply --yes
php scripts/bird-control-plane-schema.php check

apply is CLI-only and requires the explicit --yes guard. The status endpoint publishes read-only schema: {ready, version, expectedVersion, missing} state. Ledger-dependent reads, webhook ingestion, messages, and operational actions fail closed with HTTP 503 and code bird_schema_not_ready until the preflight is ready. Explicit activation applies and verifies the schema before enabling any Bird write switch.

Fail-closed production activation

Bird activation is deliberately separate from PHP-FPM and nginx startup, so a Bird provider or configuration failure cannot make the core API unavailable. Run the guarded activation explicitly in the deployed API container:

php /var/www/html/scripts/bird-control-plane-auto-activate.php

The command first transactionally disables bootstrap readiness and all three write switches. It then canonicalizes the existing legacy workspace/channel configuration, applies and checks schema version 1, validates configured channels and conversations, resolves the access-key participant, pins the public webhook URL to https://api.truckwash.io:4433/bird/webhooks/notifications, and reconciles and verifies webhooks while writes remain dark. Only then does one transaction enable bootstrap readiness, the Control Plane, outbound messaging, and operational-action switches, followed by final readiness checks. Any failure returns a non-zero exit code and re-disables the Bird capabilities without stopping or restarting the core API. It preserves existing valid credentials; otherwise it generates distinct 48-byte random Control Plane and webhook secrets inside the container. The webhook key remains backend-only.

The Control Plane token is sealed with the committed RSA-3072 public key using RSA-OAEP-SHA256 with MGF1-SHA256. OpenSSL receives plaintext only over child stdin, never argv. Only the ciphertext, RSA-OAEP-256 algorithm identifier, SPKI SHA-256 fingerprint 6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21, monotonic token version, and UTC update timestamp are bootstrap-visible. Existing ciphertext is retained only when its internal token hash and all metadata remain valid, so normal deployments do not rotate a working token.

Webhook subscription reconciliation runs before service readiness. The reconciler first requires Bird's available-webhooks response to advertise the conversations service, both conversation.created and conversation.updated, and channelId filtering for each. It prefers the documented organization/workspace list after deriving a single UUID-like organization ID from workspace-consistent channel/conversation metadata.

When no organization ID is discoverable, the read-only preflight may probe the existing workspace-scoped subscription list endpoint. That fallback is accepted only when it returns an explicit results, items, or data collection with bounded pagination. Any unsupported response, ambiguous page, or provider error becomes organization_id_required before a POST or PATCH. The reconciler creates one exact subscription for each event and allowlisted channel, or patches only an existing subscription with the exact Pleno URL, event, and sole channel filter. It never deletes or mutates unrelated subscriptions.

Any schema, provider, encryption, subscription, or final readiness failure transactionally restores bootstrap readiness and all three write switches to false, then exits container startup before PHP-FPM/nginx starts. Flow and template automation remain disabled unless their existing versioned policies are explicit, non-empty, and structurally valid; Flow also requires an existing strong shared secret.

Local Control Plane credential bootstrap

The unauthenticated GET /bird/control-plane/v1/bootstrap response is Cache-Control: no-store and contains only the fixed validated ciphertext envelope. Unavailable state always returns the same 404 Not found response. The endpoint never returns plaintext, hashes, provider credentials, or the backend-only webhook key.

On the Control Plane host, run:

scripts/bird-control-plane-bootstrap-local.sh

The script is pinned to https://api.truckwash.io:4433 and accepts no URL override. It validates the exact algorithm, public-key fingerprint, fixed ciphertext shape, version, and timestamp, decrypts using the local 0600 private key, and writes only a temporary 0600 candidate. It immediately proves the bearer against the authenticated status endpoint and atomically retains it as /home/jeppe/.openclaw/credentials/bird.gateway-token only after success.

Endpoints

Voice calls

  • POST /bird/voice/calls

    • Permission: modules_bird_voice_calls_create
    • Required: workspaceId, channelId
    • Body: passthrough to Bird call create API (except workspaceId and channelId).
  • GET /bird/voice/calls

    • Permission: modules_bird_voice_calls_list
    • Required query: workspaceId, channelId
    • Query params are passed through to Bird.
  • GET /bird/voice/calls/{id}

    • Permission: modules_bird_voice_calls_get
    • Required query: workspaceId, channelId
  • POST /bird/voice/calls/{id}/hangup

    • Permission: modules_bird_voice_calls_hangup
    • Required: workspaceId, channelId
  • POST /bird/voice/calls/test-outbound

    • Permission: modules_bird_voice_calls_test_outbound
    • Required: workspaceId, channelId
    • Places an outbound call to the fixed test number +45 42 33 11 28 (+4542331128), polls call state, and sends hangup when state is accepted or ongoing.
    • Optional tuning params:
      • pollIntervalSeconds (default 2)
      • maxPollSeconds (default 30)
      • hangupCause
  • POST /bird/voice/calls/webhook/inbound

    • Permission: modules_bird_voice_call_webhooks_trigger
    • Stateful inbound IVR webhook for phone-controlled department gates.
    • On the initial request, the webhook accepts the live inbound call through Bird before returning the first gather command.
    • Supports both request formats:
      • Native-flow mode: top-level { callId, channelId, workspaceId } for the initial fetch, then { callId, channelId, workspaceId, keys } for selections.
      • Compatibility mode: { payload, request, waitConditions }, which returns a raw Bird callCommand gather envelope.
    • Uses department_gates rows with config.type=PHONE_CALL as the source of truth for which departments and entrance/exit gates are offered.
    • Department menu order follows departments.order_priority.
    • Supports multi-digit department selections such as 10#.
    • Always prompts for both department selection and gate selection, even when only one valid option exists.
    • Uses compact gate numbering:
      • both gates available: 1=entrance, 2=exit
      • entrance only: 1=entrance
      • exit only: 1=exit

Bird Flow Builder setup

  • Preferred native-flow setup:
    • Voice trigger
    • Answer call
    • Async HTTP request (initial IVR state fetch)
    • Gather digits from a call
    • Async HTTP request (submit selected keys)
    • If the second HTTP response returns completed=false, run another Gather digits from a call with the returned prompt and gather settings.
    • If the second HTTP response returns completed=true, end the flow or optionally say the returned message.
  • The caller must be connected in Bird before the HTTP webhook step runs. If the flow omits Answer call, the inbound call can keep ringing until Bird times it out.
  • Initial HTTP step:
    • URL: https://api.truckwash.io:4433/bird/voice/calls/webhook/inbound
    • Content type: application/json
    • Body:
{
  "callId": "{{callId}}",
  "channelId": "{{channelId}}",
  "workspaceId": "{{workspaceId}}"
}
  • Example native-flow response:
{
  "requestId": "request-123",
  "callId": "4015cf84-8028-46a1-a0d9-9213e5bf4f09",
  "status": "gather",
  "completed": false,
  "stage": "department_select",
  "prompt": "Choose department. Press 1 for Roskilde. Press 2 for Demo.",
  "gather": {
    "input": "dtmf",
    "maxNumKeys": 1,
    "endKey": "#",
    "timeout": 30,
    "retries": 3,
    "say": {
      "locale": "en-US",
      "voice": "female",
      "text": "Choose department. Press 1 for Roskilde. Press 2 for Demo."
    }
  }
}
  • Use the initial HTTP step output to configure Gather digits from a call:
    • Prompt/text from prompt or gather.say.text
    • maxNumKeys, endKey, timeout, and retries from gather
    • TTS locale and voice from gather.say
  • Selection HTTP step:
    • POST the same callId, channelId, and workspaceId plus the gathered digits as keys.
    • Example body:
{
  "callId": "{{callId}}",
  "channelId": "{{channelId}}",
  "workspaceId": "{{workspaceId}}",
  "keys": "{{gatheredKeys}}"
}
  • Bird's current Voice API examples use BCP-47 locales such as en-US and TTS voices such as female/male. Avoid undocumented values like alice.
  • Compatibility mode:
    • The webhook still supports the older { payload, request, waitConditions } contract and returns a raw 202 Accepted Bird callCommand gather envelope.
    • That mode is not suitable for Bird Flow Builder Async HTTP request, because Bird treats the response as data rather than executing the returned call command.

Numbers

  • GET /bird/numbers

    • Permission: modules_bird_numbers_list
    • Optional query: workspaceId (if not configured)
    • Query params are passed through to Bird.
  • GET /bird/numbers/{id}

    • Permission: modules_bird_numbers_get
    • Optional query: workspaceId (if not configured)
  • DELETE /bird/numbers/{id}

    • Permission: modules_bird_numbers_delete
    • Optional query/body: workspaceId (if not configured)
    • Releases/deletes a number if supported by Bird account policy.

Logging

Bird requests are logged through logs_o with module bird, including:

  • Request start (BIRD_HTTP_REQUEST)
  • Response status (BIRD_HTTP_RESPONSE)
  • API or transport errors (BIRD_HTTP_ERROR, BIRD_HTTP_CURL_ERROR)
  • Test outbound flow events (BIRD_TEST_OUTBOUND_CALL_*)

Notes

  • Bird credentials and base URL are configured in Bird module config.
  • Route handlers enforce workspace/channel requirements before outbound API calls.