Skip to content

Bland calling and public reception

This canonical Milestone 27 design implements the owner's approved first-release proposal under ADR-0097.

Experience

Veetbot gains a dedicated phone number and two capabilities:

  • Make a call for the owner. In Chat, the owner supplies a recipient and objective. Veetbot prepares a call brief, obtains the existing human approval, starts the call, and later reports the outcome. For example, “Call the repair shop and ask whether my laptop is ready” produces a proposed recipient, objective, and the specific identifying information permitted to leave.
  • Answer calls for the owner. Anyone may call the dedicated number. The receptionist introduces itself by name as the owner's assistant, answers using an owner-curated public profile, and takes a message. Veetbot receives a call record, transcript, and summary for the owner to read and act on.

The greeting is the owner's chosen introduction, “Hi, this is Willow, Andy's assistant.” (ADR-0108 replaced the original proactive disclosure). Neither inbound nor outbound calls announce AI identity or transcription unprompted. Asked directly, the assistant confirms that it is an AI assistant and that the call is transcribed for the owner. Audio recording is disabled by default; transcription is still part of the service.

The reviewed configuration distinguishes assistant_name (default Veetbot) from public_name, the owner the assistant represents, and from voice, the provider voice selection. Both inbound greetings and outbound instructions use the assistant and owner names: “Hi, this is Willow, Andy's assistant.” All three values are included in the configuration revision that binds outbound approval; renaming the assistant requires a fresh approval and an inbound configuration readback.

The initial receptionist collects the caller's stated name, organization if relevant, reason for calling, requested callback details, and any stated deadline. It confirms these details and says it will pass the message along. It does not promise that a callback, booking, payment, or other action has happened.

Live conversation and owner authority

Bland handles telephony and the live speech conversation. Veetbot owns the approved outbound brief, public receptionist profile, call records, owner-facing results, and any subsequent action. This first version does not stream every spoken turn through Veetbot's durable worker.

Boundary Rule
Public caller An external correspondent; never the owner, even if caller ID matches the owner's number
Inbound context Only the separately curated public receptionist profile and the current call
Outbound context Only the approved brief and its explicitly disclosed facts
Private context No automatic upload of persona, memory, email, contacts, conversations, or credentials to Bland
Live actions No Veetbot tool bridge, arbitrary HTTP tools, transfers, or financial/scheduling authority in the initial call agent
Subsequent actions Proposed in the owner's ordinary Veetbot session and subject to the existing policy and approval flow
Caller claims Attributed to the caller, with phone numbers treated as unverified contact information

The absence of private context and backend capabilities is enforced by configuration and transport boundaries. A prompt alone is not the control. A caller may manipulate the conversation, but cannot gain capabilities that the call agent never receives. Public-profile changes are owner-authenticated and versioned; provider configuration drift disables activation until reconciled.

The owner approves an outbound brief, not a verbatim transcript: a voice model generates its actual replies during the call. The approval includes the recipient in E.164 format, caller ID, purpose, disclosed facts, maximum duration, recording setting, any voicemail message, and pinned call-agent configuration. A changed recipient, brief, voicemail message, or configuration invalidates that approval. Veetbot must not claim to guarantee the wording of generated speech or use this initial integration for binding commitments.

An unanswered call either hangs up or leaves the approved voicemail. The provider's voicemail action is always explicit, hangup or leave_message. Its SMS variants are refused because SMS is deferred scope. A voicemail is exact text the owner approves, at most 1,000 characters, never speech generated during the call. Every outbound call waits for the recipient to speak first. Its opening is then neither clipped at connection nor spoken over a voicemail greeting or a call-screening prompt. The provider documents no silence timeout, so a person who answers and stays silent hears nothing until they speak, within the call's duration limit. The provider may not mark a reached voicemail as such: it reported one screened call as answered_by: "unknown". The transcript and summary show what happened.

Repository fit and the required plan change

Voice remains in roadmap B11 of the engineering plan. The inbound-surfaces design permits owner runs only after pairing and stores no content from an unpaired sender. Public reception therefore needs its own explicit correspondence-intake contract. It must not be implemented by pairing every caller to the owner, assigning USER trust to transcripts, or weakening Telegram/WhatsApp's existing pairing behavior.

The architecture admits authenticated provider call records belonging to the owner's configured number, without creating a run on the caller's behalf. Owner-requested reads bring those records into ordinary sessions as EXTERNAL_UNTRUSTED tool results. The existing communication-memory adapter admits specific source contracts, not arbitrary MCP data; call transcripts do not become automatic long-term memories in this first release. A later evaluated source extension would need its own attribution, erasure, and precedence checks.

The owner approved this scope on 2026-09-11 under ADR-0097. Milestone 27 registers fourteen gates and does not advance the verified ceiling beyond 12. Public call records are an explicit correspondence-storage exception; callers receive no owner authority and existing paired channels retain their contracts.

Outbound tools and lifecycle

Use a small repository-owned Bland REST client exposed through the existing MCP adapter, following the first-party Gmail separation. Two mode-confined server rosters keep reads and call dispatch honestly classified:

Server Tools Classification
bland_read list_calls, get_call NETWORK_READ / LOW / READ_ONLY
bland_call start_call EXTERNAL_MESSAGE / HIGH / NON_IDEMPOTENT

The official Bland MCP server also exposes administrative mutations and broad API passthrough. Those capabilities are unnecessary in the owner's model tool catalog. Number purchase, agent publication, and account changes stay in operator setup. Bland tool reference

The provider returns a call identifier on dispatch. A successful API response means that the call was accepted, not that the recipient answered or the task succeeded. Save the identifier, owner, approved invocation, agent revision, and outbound correlation identifier durably. Final states distinguish no answer, busy, voicemail, completed conversation, failure, and an uncertain dispatch.

Use the executor's existing effect watermark and uncertain-outcome handling.

A timeout, disconnection, malformed success response, or crash after possible dispatch must never cause an automatic redial. Reconcile using provider call history and the correlation identifier; unresolved ambiguity remains visible to the owner. Metadata is correlation evidence, not a provider idempotency key. Owner cancellation before dispatch prevents the call; after dispatch the integration must attempt provider termination and report whether termination is confirmed. Cancelling a Veetbot run alone does not end a telephone call.

The REST adapter uses the fixed Bland API origin with authenticated TLS, no redirects, bounded response bodies, strict argument validation, redacted output, and content-free errors. API credentials never enter tool arguments or model context. The implementation adds no major dependency unless a separate decision justifies it. Send Call, Call Details

Owner-requested and scheduled calls (ADR-0213)

A call the owner asks for needs no confirmation card. An application standing authorizer satisfies the deterministic approval for start_call when the run is the owner's top-level chat or a scheduler occurrence, the newest user message is USER, nothing anywhere in the conversation came from email, the web, a document or a call record, and the dialed number appears in the owner's own messages. A scheduled occurrence's owner-approved instruction counts as the owner's message. Any other call keeps the card, including one whose number came only from memory or People. AGENT_CALL_OWNER_REQUESTS_ENABLED=0 restores the card for every call. Dispatch still enforces the pinned configuration, caller ID, recording and duration checks above.

"Call Riv at +1 (650) 555-0123 at 6:15" is one schedule.create with place_call=true, which the same authorizer allows when every number in its instruction is one the owner gave. Its definition requests exactly the two calling scopes, so the occurrence can call and read the outcome. Creation fails with schedule.calling_unavailable when calling is not composed, rather than at firing; the scheduler role mirrors AGENT_CALL_ENABLED to vouch for the scopes.

The server's stderr is not a private channel: the API or worker process that spawns the child passes on its own stderr, so anything the child logs lands in that service's journal. The package therefore holds HTTP transport logging at warning level. At the SDK's default informational level, every request line would carry provider call identifiers, the configured number and reconciliation query values.

Inbound number and result delivery

First inspect the account's existing numbers without making calls or purchasing anything. If none is suitable, select a number and plan with the owner. Bind one verified account/number pair to the configured Veetbot principal; never derive the owner from caller-supplied data or an arbitrary webhook metadata field.

Configure the number with the reviewed receptionist profile, explicit duration limit, recording disabled, and a post-call callback. The callback reports a completed call; it is not a realtime connection to Veetbot's reasoning loop. Inbound configuration, Post-call webhooks

Callback processing:

  1. Terminate HTTPS at the existing proxy and route only the callback path to a loopback listener in a restricted telephony ingress role. The listener does not hold a model key or general tool authority.
  2. Bound the raw body before parsing. Verify the documented SHA-256 HMAC using X-Webhook-Signature and a separate webhook secret, with constant-time comparison. Prove the signed-byte convention against a real provider fixture; do not silently accept both raw and reserialized variants. Bland's example reserializes JSON, so transport-level verification needs explicit evidence.
  3. Treat a verified signature as provider authentication only. Validate the call identifier, direction, configured number, and account binding. Retrieve call details through the credential-bearing provider adapter before accepting an unknown call; invalid and foreign calls store only content-free rejection evidence. The ingress role enqueues a bounded call identifier for this check, not arbitrary caller content or a provider-chosen URL.
  4. Atomically persist the normalized, principal-bound record and its receipt. Duplicate callbacks and retries produce one result. A replay cannot resurrect erased content: retain a content-free tombstone. Out-of-order updates cannot regress a terminal state or overwrite already accepted source content. Reject provider-verified calls older than the retention window, including during reconciliation, so expired tombstones do not reopen historical intake.
  5. Make the result available to authenticated owner reads. Use bounded reconciliation to recover missed callbacks and incomplete outbound results; it reads call state and never redials. A post-call callback without a signed timestamp is not freshness evidence, even when its signature is valid.

Bland webhook signing

Call records include provider identity, direction, timestamps, duration, caller claims, transcript, summary, result status, and provenance. Full records are owner-only. Logs, metrics, rejection records, and push payloads contain no call content. Neither the proxy nor the listener keeps an access log, so each delivery writes one content-free warning: calling.callback_accepted, or calling.callback_rejected with a fixed reason. The reasons are oversize, timeout, signature_missing, signature_malformed, signature_mismatch, payload_invalid and queue_full. It carries no body, signature, call identifier or caller data, and it is the only evidence of whether the signed convention holds. Initial retention is thirty days for transcripts and summaries, with owner deletion and durable erasure of derived local copies. Provider-side retention is a separate account setting to verify during setup; local deletion must never be presented as deletion from Bland.

The notification catalog registers call_finished with the fixed title “New call result” and only call and notification identifiers. Owner-authenticated Apple deep links open a plain-text result sheet in Chat; transcripts never enter the push payload and no new client mode is introduced.

Configuration and activation

All flags default to zero: AGENT_CALL_ENABLED enables tools, owner reads and the result worker; AGENT_CALL_INGRESS_ENABLED separately enables signed intake; AGENT_CALL_NOTIFICATIONS_ENABLED enables the closed notification trigger and requires the existing notification API. BLAND_CONFIGURATION_FILE names the bounded, reviewed public JSON configuration; BLAND_API_KEY_FILE is loaded only by credential-bearing roles and BLAND_WEBHOOK_SECRET_FILE only by ingress. Both calling roles refuse to start with a model or browser credential variable, and ingress also with BLAND_API_KEY_FILE. The setup guide defines number review, profile generation, role grants, signature verification, activation and rollback.

agent call-worker polls durable receipts every ten seconds and scans one 25-ID provider page per direction per minute. Receipts use capped exponential backoff up to one hour; due receipts are selected before the 25-record limit. Receipt retry timestamps are offset-aware and normalized to UTC at record creation, storage and hydration. Missing or null timestamps are immediately due. Malformed values fail closed; existing malformed rows require operator repair from verified receipt state with calling disabled before activation, rather than guessing a timestamp or replaying a dispatch. One thousand active receipts bound ingress admission. Erased-call tombstones are content-free and retained to prevent replay. Missing provider summaries or transcripts may be filled after completion; accepted source text and terminal state cannot be overwritten. Local source erasure also redacts subsequent generated content and tool arguments in affected sessions, since later turns may paraphrase the source without retaining its identifier; original owner messages remain intact. Active runs defer erasure. Expired content is hidden immediately and physical artifact cleanup uses maintenance.

agent call-ingress uses a separate Linux user, receipt-only database role, loopback port 8003 and a 256-KiB raw-body limit. The main owner API exposes call.read, call.cancel and call.delete routes under /v1/calls; no public caller obtains owner authentication or a run.

Load secrets from absolute owner-only regular files outside the repository, using secret-typed fields and the existing child-environment confinement. Credential validation must not print values. A local bootstrap command can accept the key through a hidden prompt; the user need not paste it into chat. Never replace an existing account webhook secret without identifying affected integrations.

Release preflight runs as the unprivileged deploy identity without sudo. It reads both calling environment files, and checks each credential's owner, mode and ACL without opening it. Decision (2026-09-16): each calling environment file is root:veetbot mode 0640, like the application environment. Every directory above a credential is traversable (0711) by the deploy identity. The credentials stay mode 0600 with base ACL entries only, owned by their service. The deploy identity therefore reads no credential. The listener stays outside the veetbot group, so it still cannot read the application environment. That group gains read access to the two calling database passwords. Its members already read the application environment, which holds the broader application database credential. Reading these files through the deploy sudo contract was rejected: it would add root reads of paths taken from the environment files. The setup guide holds the commands, and deploy/app/release.test.sh replays them as each deploy identity.

Initial call limits are five minutes per call and one concurrent outbound call. Inbound concurrency follows the reviewed provider plan's limits; a stricter one-at-a-time inbound limit is optional and is not an activation requirement. These limits do not authorize placing a live call. Outbound admission uses durable reservations so concurrent workers cannot exceed the outbound bound. Provider-hosted inbound calls begin before the post-call callback, so local callback throttling cannot enforce their telephone spending or concurrency. Activation must verify provider-side controls and the owner's chosen plan; post-call accounting cannot be represented as a hard spending cap.

Bland documents an Agent Phone Plan with a dedicated US number and limits shared across inbound and outbound calls. Busy behavior and supported destinations need to be tested against the actual account. Pricing, number availability, and account eligibility are setup decisions, not assumptions about this owner's account. Agent Phone Plan

Activation requires a suitable number, reviewed public name/profile, secret files, webhook delivery configuration, and an explicit live test recipient. One inbound call and one owner-authorized outbound call establish the external integration; fixture tests cannot establish live voice quality, number routing, or account permissions. No purchase, subscription change, live call, or deployment has been performed as part of local implementation.

Hard gates

  1. Default off. Disabled calling exposes no tools, ingress, reconciliation or alerts; incomplete enabled configuration fails closed. M27.

  2. Tool separation. Scoped read and call rosters are fixed and honestly classified with no provider administration or arbitrary API passthrough. M27.

  3. Approval binding. Every outbound call is approved on its card — exact recipient, brief, facts, limits, voicemail message and pinned configuration — or authorized by the owner's own request (ADR-0213); a call derived from email, web or call content always needs the card, and changes, denial, expiry and revocation prevent dispatch. M27.

  4. Dispatch recovery. Crashes or lost responses after possible dispatch never redial and preserve uncertain outcomes. M27.

  5. Cancellation. Cancellation prevents undispatched calls and requests provider termination after dispatch, with an honest result. M27.

  6. Provider boundary. Provider requests use fixed HTTPS origins, refuse redirects, bound responses and sanitize credentials and errors. M27.

  7. Public isolation. Callers and spoofed caller IDs gain no owner scopes, private context, backend tools or automatic memory authority. M27.

  8. Callback authentication. Signatures precede parsing and storage; size, direction, account, number and call ownership are validated. M27.

  9. Durable receipts. Duplicate, reordered, missed and replayed callbacks produce one result without resurrecting erased content. M27.

  10. Repository isolation. Shared memory and PostgreSQL contracts enforce principal isolation, forced tenant RLS and atomic admission. M27.

  11. Retention and erasure. Owner deletion and thirty-day expiry erase local transcript and summary copies without claiming provider deletion. M27.

  12. Notifications. The declared call-result trigger produces only content-free owner notifications and authenticated deep links. M27.

  13. Admission bounds. Durable outbound reservations, bounded reconciliation and verified provider-side inbound controls enforce documented limits. M27.

  14. Live release. Authorized real inbound and outbound calls, signed callbacks, voice quality and final-head review and release evidence pass before completion. M27.