Subject binding & project users
Run user-scoped agents on WhatsApp, Telegram, Teams, and other channels without letting the AI choose who the user is.
The rule (no exceptions)#
| Question | Who decides | Example |
|---|---|---|
| May this person use orders_agent? | Channel access grants (Hub) | WhatsApp grant for * |
| Whose orders are queried? | Platform code (invoker binding) | user_id = ACME-1001 |
| Which endpoint runs? | AI | orders_agent.main.list_my_orders |
| Optional status filter? | AI (only if source: llm) | status = open |
End-to-end example: Acme orders on WhatsApp#
Acme Corp assigns orders_agent to customers on WhatsApp. Each person has a row in the user directory (acme_user_id, email, phone). When someone messages "list my orders", the platform resolves their phone → row → injects ids → agent queries the DB.
- 01Define schema — primary key, fields, and match rules (how WhatsApp/Telegram map to a row).
- 02Import users — CSV or SCIM with email, phone,
acme_user_id. - 03Channel access — grant
orders_agenton WhatsApp (Channel Hub). - 04Agent metadata — mark
user_id/user_emailassource: invoker. - 05Agent code — read
payload["user_id"]; do not parse identity from chat text. - 06Deploy & test — message from a known phone; check audit log for injected fields.
Step 1 — Project user schema#
Configure once per project in Volt → Channel Hub → Customer / User base (strip below the Volt Channels header), or via the project-users admin API. You define your own columns and how each channel matches to a row.
{
"primary_key": "acme_user_id",
"fields": [
{ "name": "acme_user_id", "type": "string", "required": true, "unique": true },
{ "name": "email", "type": "email", "unique": true },
{ "name": "phone_e164", "type": "phone", "unique": true },
{ "name": "full_name", "type": "string" },
{ "name": "is_admin", "type": "boolean" }
],
"match_rules": [
{ "channel": "whatsapp", "field": "phone_e164", "source": "connector.phone" },
{ "channel": "telegram", "field": "telegram_user_id", "source": "connector.external_user_id" },
{ "channel": "teams", "field": "email", "source": "connector.email" },
{ "channel": "*", "field": "email", "source": "connector.email", "fallback": true }
]
}| match_rule source | Meaning |
|---|---|
| connector.external_user_id | Telegram id, Slack user id, WhatsApp chat id, … |
| connector.email | Profile or verified email from connector |
| connector.phone | Phone from webhook metadata (normalized E.164) |
Step 2 — Bulk user CSV#
Header row must include your primary_key column. Import from the Customers tab in Channel Hub, or via POST /internal/volt/project-users/users/import-csv.
acme_user_id,email,phone_e164,full_name,is_admin
ACME-1001,[email protected],+15551234567,Alice Smith,false
ACME-1002,[email protected],+15559876543,Bob Jones,falseStep 3 — Map agent inputs (builder / agent JSON)#
Each input declares a source. Identity fields use invoker plus a bind path into the resolved user row.
{
"agent_alias": "orders_agent",
"inputs": [
{
"name": "user_id",
"type": "string",
"source": "invoker",
"bind": "profile.acme_user_id",
"description": "Injected by platform — never from chat"
},
{
"name": "user_email",
"type": "string",
"source": "invoker",
"bind": "identifiers.email"
},
{
"name": "status",
"type": "string",
"source": "llm",
"required": false,
"description": "Optional: open, shipped, cancelled"
}
],
"agent_endpoints": [
{
"endpoint": "orders_agent.main.list_my_orders",
"description": "List orders for the current customer",
"status": "enabled",
"subject_binding": "strict",
"inputs": [
{ "input_ref": "user_id", "required": true },
{ "input_ref": "user_email", "required": true },
{ "input_ref": "status", "required": false }
]
}
]
}| Field | Value | Notes |
|---|---|---|
| source | invoker | llm | constant | context | Default is llm |
| bind | profile.acme_user_id | Dot path into invoker snapshot |
| subject_binding | strict | auto | none | strict rejects LLM attempts to set subject fields |
| access_level | admin | Optional — requires is_admin on user row |
Step 4 — Agent Python code#
Functions stay async with a single payload: Dict[str, Any]. Identity is already in payload when your code runs — treat it as trusted platform input.
from typing import Any, Dict
from viksa_ai.runtime import mcp_endpoint
@mcp_endpoint(description="List orders for the authenticated customer")
async def list_my_orders(payload: Dict[str, Any]) -> Dict[str, Any]:
# Platform injected these — NOT from the user's WhatsApp message
user_id = payload["user_id"]
user_email = payload["user_email"]
status = payload.get("status") # optional filter from AI
orders = await orders_db.list(user_id=user_id, status=status)
return {
"user_id": user_id,
"count": len(orders),
"orders": orders,
}Optional snapshot for logging or defense in depth: payload["__viksa_invoker__"] (also in Pulse envelope.invoker).
What the user experiences#
- Matched user — "list my orders" → agent runs with their
acme_user_id. - Unmatched user — fixed message from schema (blocked before agent); no AI guessing email.
- Ambiguous match — two rows share the same phone; blocked until admin fixes data or links identity manually.
Customer directory API#
Admin APIs live on directory-sync-service at gateway prefix https://api.viksaai.com/directory-sync. Runtime identity linking, unmatched users, and audit remain on volt-engine-service at https://api.viksaai.com/volt-engine. Full endpoint tables: Directory sync & project users API.
https://api.viksaai.com/directory-sync/internal/volt/project-users| Endpoint | Use when |
|---|---|
| POST /users/list | Paginated index — search, data source filters, enabled_only |
| POST /users/get | Full record — profile, data_source, identity links, grants |
| POST /users/upsert | Create or update a customer (syncs connector primary keys) |
| POST /users/delete | Delete one customer by customer_user_id |
| POST /users/delete-bulk | Start async purge for up to 500 customers by id list |
| POST /users/delete-preview | Count rows matching search or data-source filters before delete |
| POST /users/delete-by-filter | Start async purge for up to 5000 customers matching filters |
| POST /users/purge/get | Poll purge progress by purge_id |
| POST /users/purge/list | List purge runs; active_only for in-flight jobs |
| POST /users/data-sources/list | Grouped counts by data source (for filters and bulk delete by source) |
| POST /users/import-csv | Bulk upsert from CSV (tags rows with csv data_source) |
| POST /users/export-csv | Export customer rows as CSV |
| POST /users/set-connector-enabled | Disable WhatsApp only while Slack stays on |
| POST /users/attach-channel-grant | Link customer + assign channel agents |
Each customer stores a data_source object (manual, csv, scim, or directory_sync). The Channel Hub Customers tab shows this in the Data source column.
Precedence: master enabled blocks all connectors; when the account is enabled, each channel has its own connector_enabled flag (defaults to on). Disabling from WhatsApp access or the customer Channels tab updates the same master record and grant.
Ops: unmatched users, audit, identity links#
Use the Unmatched and Audit tabs in Channel Hub, or link connectors from the customer detail drawer. These runtime endpoints stay on volt-engine:
https://api.viksaai.com/volt-engine/internal/volt/project-users| Endpoint | Use when |
|---|---|
| POST /identities/link | Manually bind WhatsApp +91… → ACME-1001 |
| POST /identities/unlink | Revoke grant and remove connector link |
| POST /identities/list | Links for one customer_user_id |
| POST /identities/list-all | Paginated all connector → customer links (customer detail) |
| POST /unmatched/list | See connector directory users with no project link |
| POST /audit/list | Review injected user_id / email per dispatch |
Common mistakes#
| Mistake | Fix |
|---|---|
| user_id still source: llm | Change to source: invoker + bind; redeploy agent |
| AI asks "what is your email?" | Invoker inputs hidden from catalog — use invoker binding |
| User not found on WhatsApp | Import row with phone_e164; check match_rule for whatsapp |
| Link Matrix per-user rows | Use project_users — Link Matrix is for env/config mappings only |