Outcome
Resolve an existing contact identifier by exactly one email or CRM contact id without writing.
Before you start
- Verify the environment, credential, authorized organization and the published action permission workspace:read.
- Discover an accessible Engage workspace and bind resourceType=workspace and its resourceId separately from action input. Fixture identifiers grant no access.
Discover the target
- Select the intended workspace and supply exactly one email or crmContactId from your authorized source. The latter matches custom.crm_contact_id set by CRM imports.
topolo whoami --json
topolo actions get app_topolo_engage.contacts.resolve --json
topolo workspaces list topolo-engage --json
Input fields
| Field | Required | Meaning and constraints |
|---|---|---|
email | No | Optional intended email, trimmed 3–254 characters; supply it exclusively with no crmContactId. Runtime normalization validates it. |
crmContactId | No | Optional source CRM contact identifier, trimmed non-empty string up to 128 characters; supply it exclusively with no email. |
Validate, plan and execute
Set ENGAGE_WORKSPACE_ID to the identifier of your selected workspace. Prepare payload.json from the current input schema and discovered identifiers. Use the same payload for validation, planning and execution.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions validate app_topolo_engage.contacts.resolve --data @payload.json --json
topolo actions plan app_topolo_engage.contacts.resolve --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --json
Execute the unchanged validated read in the selected workspace.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions call app_topolo_engage.contacts.resolve --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --json
For MCP, use topolo_get_action, topolo_validate_action, topolo_plan_action and topolo_call_action. Pass the selected resource context separately; confirm:true is required for mutations.
Worked requests and responses
Captured contacts.resolve · HTTP 200
Action input
{
"email": "ADA@Acme.io"
}
Actual local response
{
"contactId": "contact_1",
"archived": false
}
Source test: audience routes > contacts.resolve > finds a contact by normalised email or CRM id for a reader, and never creates one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 200
Action input
{
"email": "ada@acme.io"
}
Actual local response
{
"contactId": "contact_1",
"archived": true
}
Source test: audience routes > contacts.resolve > reports an archived contact and stays inside the workspace. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 200
Action input
{
"crmContactId": "crm_1"
}
Actual local response
{
"contactId": "contact_1",
"archived": false
}
Source test: audience routes > contacts.resolve > finds a contact by normalised email or CRM id for a reader, and never creates one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 200
Action input
{
"email": "nobody@acme.io"
}
Actual local response
{
"contactId": null,
"archived": false
}
Source test: audience routes > contacts.resolve > finds a contact by normalised email or CRM id for a reader, and never creates one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 200
Action input
{
"crmContactId": "crm_none"
}
Actual local response
{
"contactId": null,
"archived": false
}
Source test: audience routes > contacts.resolve > finds a contact by normalised email or CRM id for a reader, and never creates one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 200
Action input
{
"email": "ada@acme.io"
}
Actual local response
{
"contactId": null,
"archived": false
}
Source test: audience routes > contacts.resolve > reports an archived contact and stays inside the workspace. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Effects and response meaning
- Never creates a contact. Returns contactId and archived; no match returns contactId:null and archived:false. A returned archived:true contact must not be treated as active.
- Email uses the same normalized, salted address lookup as creation/imports. CRM matching prefers an active contact, then earliest createdAt when multiple rows match; it does not return every match. Passing both or neither lookup fields is refused.
Verify the result
- When contactId is non-null, read contacts.get and compare the intended email or custom.crm_contact_id and archived state. When null, stop: creation or CRM import requires a separate reviewed operation.
Recover
Retry this read after a transient failure in the same workspace using the same single lookup field. Null is a valid no-match outcome; do not reinterpret it as permission to create or enroll a contact.
Refused responses
Captured contacts.resolve · HTTP 422
Action input
{}
Actual local response
{
"error": {
"code": "invalid_action_input",
"message": "Pass exactly one of email or crmContactId."
}
}
Source test: audience routes > contacts.resolve > needs exactly one valid input. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 422
Action input
{
"email": "not-an-email"
}
Actual local response
{
"error": {
"code": "invalid_email",
"message": "The email address is not valid.",
"details": {
"reason": "missing_at"
}
}
}
Source test: audience routes > contacts.resolve > needs exactly one valid input. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured contacts.resolve · HTTP 422
Action input
{
"email": "a@b.co",
"crmContactId": "x"
}
Actual local response
{
"error": {
"code": "invalid_action_input",
"message": "Pass exactly one of email or crmContactId."
}
}
Source test: audience routes > contacts.resolve > needs exactly one valid input. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Error recovery
- HTTP 403
permission_denied: Stop and inspect the permission required by this action in the current credential. Do not broaden permissions or switch organization automatically. - HTTP 422
invalid_action_input: Correct the request against the current input schema and validate it again before planning. - HTTP 422
invalid_email: Correct the address according to the email normalization refusal, then validate again. Do not guess a replacement address or silently change the intended recipient.
Related actions
Evidence and limits
-
contracts/audience.mjs -
contracts/audience-filter.mjs -
apps/api/src/audience/audience-routes.ts -
apps/api/src/audience/contacts-handlers.ts -
apps/api/src/audience/contact-search.ts -
apps/api/src/audience/contacts-repository.ts -
apps/api/src/audience/lists-handlers.ts -
apps/api/src/audience/lists-repository.ts -
apps/api/src/audience/filter-source.ts -
apps/api/test/audience-routes.test.ts -
apps/api/test/segments-routes.test.ts -
Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
-
Widget/bootstrap use actual app handler but mocked worker runtime and app identity.
-
No gateway execution, live permissions, external delivery or deployed proof.
The examples are local route captures with synthetic context and simulated providers. They do not prove live authorization, CLI/MCP gateway execution, production state or delivery.