Bland calling setup¶
The Milestone 27 implementation is disabled by default. The owner approved public message-taking, answers from a reviewed public profile, individually approved outbound briefs, and owner-visible transcripts and summaries. Account activation still needs the owner's number, profile, credentials and a live test recipient. This guide does not authorize a purchase, account change, publication or test call.
Save the API key privately¶
From the repository, run this command with a new absolute path outside Git:
The hidden prompt creates a mode-0600 file and refuses to overwrite an existing
file or symlink. Do not put the key in chat, shell arguments, source control or
the public profile. Set BLAND_API_KEY_FILE to that path in the application and
call worker environments. Inline BLAND_API_KEY is rejected.
On the production host, save the key in the existing traverse-only secret directory, which is the path in the example environment:
sudo /opt/veetbot/current/.venv/bin/python -m bland_mcp bootstrap \
--output-file /etc/veetbot/secrets/bland-api-key
The command creates a missing parent directory with mode 0700. The release preflight cannot traverse such a directory (see the service roles).
Inspect the account's numbers in the Bland dashboard first. Do not buy a number automatically. Confirm the selected number belongs to the same account as the key and supports the required incoming and outgoing destinations. The account label below is a local binding, not a provider credential or caller identity.
Review the public configuration¶
Create /etc/veetbot/bland-configuration.json on the server after the owner has
chosen a number and public name. This example contains placeholders:
{
"version": 1,
"account_id": "primary",
"phone_number": "+14155550100",
"public_name": "OWNER'S APPROVED PUBLIC NAME",
"assistant_name": "Veetbot",
"public_profile": "Take messages only. No additional public facts are approved.",
"voice": "maya",
"max_duration_minutes": 5,
"webhook_url": "https://YOUR_API_HOST/webhooks/bland"
}
public_name names the owner; assistant_name names the voice assistant and
defaults to Veetbot when omitted. voice selects the provider voice independently.
For example, owner Andy, assistant Willow and voice Willow produce
“Hi, this is Willow, Andy's assistant.” Neither direction announces AI identity
or transcription unprompted; asked directly, the assistant confirms both
(ADR-0108). The same identity is included in approved outbound call instructions. Deploy a
release supporting assistant_name before adding the field; older releases
reject it. Upgrading to the explicit assistant identity changes the normalized
configuration revision, so obtain fresh approvals for any pending calls.
The file is a bounded, closed schema. Keep it writable only by the operator. Its SHA-256 revision pins the outbound approval; restart the application and workers after a reviewed change. Existing approvals using an old revision cannot dispatch against a new profile. Never copy private persona, email, contacts, memories or previous conversations into this file.
Generate the exact proposed inbound settings without network access:
uv run python scripts/prepare_bland_profile.py \
--configuration-file /etc/veetbot/bland-configuration.json
Review the generated JSON before applying it through Bland's number settings or
the operator's authenticated POST /v1/inbound/{phone_number}. It includes the
owner's introduction, truthful answers when asked about AI identity or
transcription, the approved profile, message-taking instructions,
a five-minute ceiling, recording off and no tools, transfers or dynamic context.
It explicitly clears pathway and memory references. A pathway can override the
prompt, so a prompt update alone is insufficient. Read back the number settings
and verify that every capability was actually cleared; a provider rejecting or
ignoring a clearing field blocks activation. Review any account-level memory or
persona attachment separately. Repeat the comparison after any provider change;
configuration drift must be reconciled before activation.
Update number,
Read number.
Confirm the actual plan's concurrency, rate, destination, usage and spending controls. Veetbot reserves at most one outbound call. Inbound concurrency uses the reviewed provider plan's limits; activation does not require reducing it to one call at a time. Public incoming calls begin at Bland before any webhook reaches Veetbot: local throttling cannot cap their spend. Verify provider-side limits and busy behavior before enabling a public number. Do not assume that this account has a particular plan or price. Agent Phone Plan.
Separate the two service roles¶
The application holds the private Bland credential for approved outbound tools.
The dedicated veetbot-call worker holds it for bounded result reconciliation
and termination. It has no owner API bearer or model credentials. Both calling
roles act for the owner tenant and principal but hold none of the owner's
scopes, so their environments leave AUTH_SCOPES empty. The separate
veetbot-call-ingress listener has only a webhook signing secret and receipt
database access. It verifies the raw bytes before parsing, queues only a call ID,
and listens at 127.0.0.1:8003; the public proxy exposes only /webhooks/bland.
Neither role starts while its environment sets a model key (any *_API_KEY),
VEETBOT_OPENAI_KEY or BROWSER_PROFILE_CONTROL_PLANE_CREDENTIAL_FILE, and the
listener also refuses BLAND_API_KEY_FILE; remove the variable rather than
leaving it unread.
Provision the unprivileged Linux user/group veetbot-call-ingress before enabling
its unit. Never add it to the veetbot group, which can read the application
environment file. The listener must not be able to read that file. The signing
secret belongs to veetbot-call-ingress and the Bland API key to the
application's veetbot user; no other identity can read either. Keep the
secret in its own directory, /etc/veetbot-call-ingress/, rather than in the
application's secret directory. The reviewed public configuration may be
readable by both roles.
Release preflight runs as the unprivileged deploy identity, without sudo:
veetbot on the current host and veetbot-deploy, a member of the veetbot
group, in the documented host model.
It reads both calling environment files, and inspects each credential with
stat and getfacl without opening it. Each calling environment file is
therefore owned by root:veetbot with mode 0640, like the application
environment. systemd reads the file as root before starting the unit, so the
listener needs no access to it. Every directory above a credential must let the
deploy identity traverse it: mode 0711 lets it reach the file without listing
the directory.
Install the host's acl package before enabling calling. Preflight requires
each credential to be a non-symlink regular file with mode 0600, the exact
service owner above, and only the base owner/group/other ACL entries.
Additional ACL entries are rejected, including currently masked grants.
Create distinct PostgreSQL login roles, with credentials set privately. Neither
role may own the tables, inherit privileged memberships, be a superuser, or have
BYPASSRLS, database/schema creation or unrelated table privileges. Apply the
normal migration as the migration operator, then provision these privileges:
| Role | Required access |
|---|---|
veetbot_call_ingress |
Schema usage; SELECT on alembic_version; SELECT, INSERT, UPDATE on call_records, restricted to receipt rows below |
veetbot_call |
Schema usage; SELECT on alembic_version, sessions, memories; SELECT, INSERT, UPDATE on call_records; SELECT, UPDATE on events, tool_invocations, runs, session_history_items, recall_traces, artifacts; SELECT, DELETE on checkpoints, integrated_episodes, knowledge_documents; SELECT, INSERT on notification_outbox when call notifications are enabled |
The migration forces tenant RLS on call records. In addition, the migration operator must restrict the ingress role to content-free receipts:
CREATE POLICY call_ingress_receipts_only ON call_records
AS RESTRICTIVE FOR ALL TO veetbot_call_ingress
USING (kind = 'receipt') WITH CHECK (kind = 'receipt');
Verify that the ingress role cannot read call rows, sessions, transcripts or other tables, and that it cannot insert a non-receipt row. Verify the worker can commit a call and notification atomically and can erase a call's derived copies. No sequence privileges are needed for the UUID/key-addressed calling writes. Existing general maintenance removes expired artifact bytes; call erasure first expires their records and removes knowledge derived from those artifacts.
Copy deploy/veetbot-call.env.example and
deploy/veetbot-call-ingress.env.example to their configured environment paths.
Use role-specific DATABASE_URL values. The owner tenant/principal, configuration
path and three calling flags must match the application environment. Do not copy
the full application environment into either role. Use the ingress-specific
secret directory for BLAND_WEBHOOK_SECRET_FILE.
Write the webhook signing secret to
/etc/veetbot-call-ingress/bland-webhook-secret without echoing it or putting
it in a shell argument. Then apply the ownership and modes; the commands are
safe to rerun:
sudo chown root:veetbot /etc/veetbot/veetbot-call.env \
/etc/veetbot/veetbot-call-ingress.env
sudo chmod 0640 /etc/veetbot/veetbot-call.env \
/etc/veetbot/veetbot-call-ingress.env
sudo install -d -m 0711 /etc/veetbot/secrets
sudo install -d -m 0711 -o veetbot-call-ingress -g veetbot-call-ingress \
/etc/veetbot-call-ingress
sudo chown veetbot:veetbot /etc/veetbot/secrets/bland-api-key
sudo chown veetbot-call-ingress:veetbot-call-ingress \
/etc/veetbot-call-ingress/bland-webhook-secret
sudo chmod 0600 /etc/veetbot/secrets/bland-api-key \
/etc/veetbot-call-ingress/bland-webhook-secret
sudo setfacl -b /etc/veetbot/secrets/bland-api-key \
/etc/veetbot-call-ingress/bland-webhook-secret
deploy/app/release.test.sh replays these commands and releases as both deploy
identities, so change the two together. A host prepared under earlier
guidance can fail with calling role environment is not readable by the deploy
user or calling role credential directory is not traversable by the deploy
user. Earlier guidance left the environment files owned by their service with
mode 0600, and the credential directories at mode 0700. Rerun the commands
above. If a credential is kept elsewhere, give each directory above it mode
0711.
Activate and verify¶
Keep all flags at zero until the reviewed number configuration, database roles, secret ownership and provider-side admission limits are verified:
| Setting | Meaning |
|---|---|
AGENT_CALL_ENABLED=1 |
Add the fixed Bland tool rosters, owner call API and reconciliation worker |
AGENT_CALL_INGRESS_ENABLED=1 |
Add the separate signed callback listener and exact public proxy route |
AGENT_CALL_NOTIFICATIONS_ENABLED=1 |
Enqueue call_finished; requires the existing notification API and delivery setup |
Enabling the third flag touches all three environments. Each calling role
validates on its own, so the application environment and both calling role files
need AGENT_CALL_NOTIFICATIONS_ENABLED=1, and each calling role file also needs
AGENT_NOTIFICATION_API_ENABLED=1 with AGENT_NOTIFICATION_DISPATCH_ENABLED=1,
which must always match each other. Those roles only enqueue: their push provider
stays disabled and the notification worker still delivers. Grant veetbot_call
SELECT, INSERT on notification_outbox before enabling, or its reconciliation
fails on the first finished call. A role whose environment fails validation exits
and its unit stops, so change the three files together and restart both calling
units with the application and worker units.
Give the owner API credential call.read, call.cancel, and call.delete as
needed. The model's Bland tools use separately installed MCP scopes. Incoming
callers receive none of these scopes. Notifications say only “New call result”
and contain call and notification IDs. Tapping one opens an authenticated result
sheet in Chat. The owner can also ask Chat to list or read retained calls.
Configure the account's separate webhook signing secret without rotating an
existing secret used by other integrations. The listener accepts only the
64-character lowercase hexadecimal SHA-256 HMAC in X-Webhook-Signature over
the exact raw HTTP body. Bland's example serializes JSON: prove the actual
delivered bytes against a private real fixture before enabling production
intake. Do not add an unsigned fallback or accept multiple signing conventions
without a reviewed change. Never save that secret or a real transcript in the
repository. Webhook signing.
Use the existing deployment procedure once separately authorized. A host whose
sudo contract predates calling support lacks the calling rules, so reinstall
deploy/sudoers/veetbot-deploy as deployment
describes before the first calling release. The release script validates
matching role bindings, grants the ingress user read access to the new release
and installs/restarts the optional units. It then fails unless each enabled
calling unit is still running without an automatic restart, so a unit that
cannot start fails the release. The proxy includes its call route only when the
active release enables ingress. Verify both units, HTTPS routing and release
identity. During rollback, stop both calling units before switching releases;
restart only units supported by the target release and restore its flags and
proxy configuration.
With an explicitly approved test recipient, verify one inbound call and one
outbound call. Confirm the greeting, the truthful answer when asked about AI
identity or transcription, voice quality, duration limit, exact-brief approval,
signed callback, summary, notification, deletion, no-answer behavior (a hang-up,
or the voicemail text the owner approved with the brief as voicemail_message)
and post-dispatch termination. A successful dispatch
means accepted, not answered. If its outcome is uncertain, reconciliation reads
provider history and never redials; keep the reservation until resolved.
Local transcripts and summaries expire after thirty days. Owner deletion also redacts derived chat content, checkpoints, tool results and later generated content in affected sessions; it preserves original owner messages. Active runs must settle first. Other devices refresh their presentation on the next fetch. Provider retention, recordings, external exports and backup retention remain separate operational controls: local deletion does not claim deletion at Bland.
Milestone 27 remains in progress until live admission, signed delivery and the authorized review/release evidence pass. See the canonical design and project state.