Topolo Docs Topolo Platform
Open workspace

Add contacts to list

Add discovered active contacts to an active list without sending outreach.

Outcome

Add discovered active contacts to an active list without sending outreach.

Before you start

  • Verify the environment, credential, authorized organization and the published action permission workspace:write.
  • Discover an accessible Engage workspace and bind resourceType=workspace and its resourceId separately from action input. Fixture identifiers grant no access.
  • Approve the exact mutation and its effects before CLI --confirm or MCP confirm:true. This documentation does not authorize outreach.

Discover the target

  • Discover list id through lists.list, then read lists.get in the selected workspace and inspect archivedAt. Retain the current name, description and active membership state before planning.
  • Discover each contact through contacts.list/contacts.get in the same workspace and confirm it is active. Retain prior membership before approving the batch.
topolo whoami --json
topolo actions get app_topolo_engage.lists.members.add --json
topolo workspaces list topolo-engage --json

Input fields

FieldRequiredMeaning and constraints
idYesRequired identifier discovered or retained in the same authorized workspace, trimmed non-empty string up to 128 characters.
contactIdsYesRequired non-empty array of at most 500 discovered contact identifiers, each trimmed 1–128 characters. Bind the list and all contacts to the same selected workspace; duplicate ids are deduplicated.

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.lists.members.add --data @payload.json --json
topolo actions plan app_topolo_engage.lists.members.add --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json

Review the plan and obtain approval for the exact effects before executing the unchanged request.

: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions call app_topolo_engage.lists.members.add --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --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 lists.members.add · HTTP 200

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_1",
    "contact_2",
    "contact_1"
  ]
}

Actual local response

{
  "list": {
    "id": "list_1",
    "name": "Founders",
    "description": null,
    "memberCount": 2,
    "createdAt": "2026-10-01T00:00:00.000Z",
    "updatedAt": "2026-10-01T00:00:00.000Z",
    "archivedAt": null
  },
  "added": 2
}

Source test: audience routes > lists > adds and removes members, counting only active contacts. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 200

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "list": {
    "id": "list_1",
    "name": "Founders",
    "description": null,
    "memberCount": 1,
    "createdAt": "2026-10-01T00:00:00.000Z",
    "updatedAt": "2026-10-01T00:00:00.000Z",
    "archivedAt": null
  },
  "added": 1
}

Source test: audience routes > lists > keeps lists and memberships inside their workspace. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 200

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_1",
    "contact_2",
    "contact_3"
  ]
}

Actual local response

{
  "list": {
    "id": "list_1",
    "name": "Founders",
    "description": null,
    "memberCount": 3,
    "createdAt": "2026-10-06T08:30:00.000Z",
    "updatedAt": "2026-10-06T08:30:00.000Z",
    "archivedAt": null
  },
  "added": 3
}

Source test: enrollment routes > enrolls a list and reports every contact it skipped and why. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 200

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_1",
    "contact_2",
    "contact_3",
    "contact_4",
    "contact_5",
    "contact_6",
    "contact_7",
    "contact_8",
    "contact_9",
    "contact_10",
    "contact_11",
    "contact_12",
    "contact_13",
    "contact_14",
    "contact_15",
    "contact_16",
    "contact_17",
    "contact_18",
    "contact_19",
    "contact_20",
    "contact_21",
    "contact_22",
    "contact_23",
    "contact_24",
    "contact_25",
    "contact_26",
    "contact_27",
    "contact_28"
  ]
}

Actual local response

{
  "list": {
    "id": "list_1",
    "name": "Targets",
    "description": null,
    "memberCount": 28,
    "createdAt": "2026-10-01T00:00:00.000Z",
    "updatedAt": "2026-10-01T00:00:00.000Z",
    "archivedAt": null
  },
  "added": 28
}

Source test: contacts.qualify > qualifies a list's first unqualified or stale active members, bounded to 25. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 200

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "list": {
    "id": "list_1",
    "name": "Founders",
    "description": null,
    "memberCount": 2,
    "createdAt": "2026-10-01T00:00:00.000Z",
    "updatedAt": "2026-10-01T00:00:00.000Z",
    "archivedAt": null
  },
  "added": 0
}

Source test: audience routes > lists > adds and removes members, counting only active contacts. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Effects and response meaning

  • Checks that every supplied id is an active contact in this workspace before adding memberships. Unknown or archived ids return unknown_contacts with details.contactIds; archived lists are refused.
  • Existing memberships are left unchanged. Returns list and added, the count of newly inserted memberships. Inserts run in batches of 100; the whole up-to-500 request is not one atomic transaction, so an interrupted request may leave partial progress. It neither creates contacts nor enrolls or sends them.

Verify the result

  • Read contacts.list with listId, following every nextCursor, to confirm each intended active contact is present. added counts new writes rather than total members; an added:0 retry can mean they were already present.

Recover

After an interrupted response, read all active memberships using contacts.list with listId and its cursors. Reconcile the exact approved ids before repeating the unchanged add; duplicate memberships are left unchanged, but concurrent removals must be reviewed. Removing only memberships introduced by this operation is a separate approved compensation; do not remove prior members.

Refused responses

Captured lists.members.add · HTTP 403

Refused input; do not execute

{
  "id": "x"
}

Actual local response

{
  "error": {
    "code": "permission_denied",
    "message": "The credential lacks workspace:write.",
    "details": {
      "requiredPermission": "workspace:write"
    }
  }
}

Source test: audience routes > permissions > allows reads with workspace:read and refuses writes without workspace:write. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 409

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "error": {
    "code": "state_conflict",
    "message": "Archived lists cannot be changed."
  }
}

Source test: audience routes > lists > creates, lists, edits and archives a list. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 422

Action input

{
  "id": "list_1",
  "contactIds": [
    "contact_2",
    "contact_9"
  ]
}

Actual local response

{
  "error": {
    "code": "unknown_contacts",
    "message": "Some contacts are not active contacts in this workspace.",
    "details": {
      "contactIds": [
        "contact_2",
        "contact_9"
      ]
    }
  }
}

Source test: audience routes > lists > adds and removes members, counting only active contacts. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 422

Refused input; do not execute

{
  "contactIds": [],
  "id": "list_1"
}

Actual local response

{
  "error": {
    "code": "invalid_action_input",
    "message": "Input for lists.members.add is invalid.",
    "details": [
      {
        "origin": "array",
        "code": "too_small",
        "minimum": 1,
        "inclusive": true,
        "path": [
          "contactIds"
        ],
        "message": "Too small: expected array to have >=1 items"
      }
    ]
  }
}

Source test: audience routes > lists > adds and removes members, counting only active contacts. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured lists.members.add · HTTP 422

Action input

{
  "id": "list_2",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "error": {
    "code": "unknown_contacts",
    "message": "Some contacts are not active contacts in this workspace.",
    "details": {
      "contactIds": [
        "contact_1"
      ]
    }
  }
}

Source test: audience routes > lists > keeps lists and memberships inside their workspace. 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 409 state_conflict: Read the exact retained contact or list and inspect archivedAt. Archived records cannot be edited or have list memberships changed; repeated archive is refused. Do not invent an unarchive operation.
  • HTTP 422 unknown_contacts: Inspect error details.contactIds. Resolve each identifier in the same workspace and confirm it is active; remove invalid entries only after reviewing the intended audience, then validate the exact batch again.

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.