> For the complete documentation index, see llms.txt.
Skip to main content

Check out Port for yourself ➜ 

Plain

Plain iconPlain icon
Loading version...

Port's Plain integration allows you to model Plain support resources in your software catalog and ingest data into them.

Prerequisites​

  • A Plain workspace with permission to create a machine-user API key.
  • Your Port user role is set to Admin.
  • A Plain API key created on a machine user (Settings → Machine Users → Add API key). The token looks like plainApiKey_….
    • Grant at least company:read, tenant:read, user:read, customer:read, thread:read, and timeline:read.
    • Additional permissions may be needed depending on which kinds and nested fields you sync (for example roles:read, tier:read, machineUser:read, customerTenantMembership:read, and webhook target permissions for live events).

Setup​

Choose one of the following installation methods. Not sure which method is right for your use case? Check the available installation methods.

Configuration​

Port integrations use a YAML mapping block to ingest data from the third-party api into Port.

The mapping makes use of the JQ JSON processor to select, modify, concatenate, transform and perform other operations on existing fields and values from the integration API.

Selectors​

Every kind supports pageSize (default 100, max 100) to control GraphQL list page size for that mapping block.

Additional selector flags:

  • excludeDeleted (machine-user only) - When true, skip deleted machine users during resync. When false (default), sync all machine users including deleted ones. You can filter in Port with a query such as .isDeleted == false.
  • excludeDoneThreads (thread, thread-message, discussion, discussion-message) - When true, sync only TODO and SNOOZED threads.
  • threadStatuses (thread, thread-message, discussion, discussion-message) - Optional list of thread statuses to sync (for example [TODO, SNOOZED]). Ignored when excludeDoneThreads is true. Omit it to sync every status.
  • excludeAiDiscussions (discussion, discussion-message) - When true, skip Cursor and agent-session discussions (and their messages) at fetch time.

Default mapping configuration​

This is the default mapping configuration you get after installing the Plain integration.

Default mapping configuration (click to expand)
createMissingRelatedEntities: true
deleteDependentEntities: true
resources:
- kind: company
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .name
blueprint: '"plainCompany"'
properties:
domainName: .domainName
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
- kind: tenant
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .name
blueprint: '"plainTenant"'
properties:
externalId: .externalId
url: 'if .url == null or .url == "" then null elif (.url | startswith("http://") or startswith("https://")) then .url else "https://" + .url end'
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
- kind: user
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .fullName // .publicName // .email
blueprint: '"plainUser"'
properties:
email: .email
publicName: .publicName
status: .status
role: .role.name
- kind: machine-user
selector:
query: "true"
pageSize: 100
excludeDeleted: false
port:
entity:
mappings:
identifier: .id
title: .fullName // .publicName
blueprint: '"plainMachineUser"'
properties:
publicName: .publicName
description: .description
type: .type
isCustomAgent: .isCustomAgent
isAssignableToThreads: .isAssignableToThreads
isDeleted: .isDeleted
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
- kind: customer
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .fullName // .shortName // .email.email
blueprint: '"plainCustomer"'
properties:
externalId: .externalId
email: .email.email
shortName: .shortName
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
relations:
company: .company.id
tenants: "[.tenantMemberships.edges[]?.node.tenant.id]"
- kind: thread
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
port:
entity:
mappings:
identifier: .id
title: .title // .ref
blueprint: '"plainThread"'
properties:
ref: .ref
externalId: .externalId
description: .description
status: .status
priority: .priority
tier: .tier.name
assigneeType: .assignedTo.__typename
labels: "[.labels[]?.labelType.name]"
productArea: '([.threadFields[]? | select(.key == "product_area") | .stringValue] | first) // ""'
fields: '[.threadFields[]? | .key + "=" + ((.stringValue // .numberValue // .booleanValue) | tostring)] | join(", ")'
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
relations:
customer: .customer.id
tenant: .tenant.id
assignee: if .assignedTo.__typename == "User" then .assignedTo.id else null end
machineAssignee: if .assignedTo.__typename == "MachineUser" then .assignedTo.id else null end
- kind: thread-message
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
port:
entity:
mappings:
identifier: .id
title: '(.entry.__typename // "Message") + (if .llmText then ": " + .llmText[0:80] else "" end)'
blueprint: '"plainThreadMessage"'
properties:
text: .llmText
entryType: .entry.__typename
timestamp: .timestamp.iso8601
actorType: .actor.__typename
actorId: .actor.user.id // .actor.customer.id // .actor.machineUser.id
relations:
thread: .threadId
- kind: discussion
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
excludeAiDiscussions: false
port:
entity:
mappings:
identifier: .id
title: .title
blueprint: '"plainDiscussion"'
properties:
status: .status
agentStatus: .agentStatus
visibility: .visibility
isUnread: .isUnread
channel: 'if .channelDetails == null then null elif .channelDetails.__typename == "ThreadDiscussionSlackChannelDetails" then "SLACK" elif .channelDetails.__typename == "ThreadDiscussionEmailChannelDetails" then "EMAIL" elif .channelDetails.__typename == "ThreadDiscussionCursorWorkspaceBackgroundAgentChannelDetails" then "CURSOR" elif .channelDetails.__typename == "ThreadDiscussionAgentSessionChannelDetails" then "AGENT_SESSION" else .channelDetails.__typename end'
slackChannelName: .channelDetails.slackChannelName
slackMessageLink: .channelDetails.slackMessageLink
emailRecipients: .channelDetails.emailRecipients
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
lastActivityAt: .lastActivityAt.iso8601
resolvedAt: .resolvedAt.iso8601
createdByType: .createdBy.__typename
createdById: .createdBy.userId // .createdBy.customerId // .createdBy.machineUserId // .createdBy.systemId
relations:
thread: .threadId
- kind: discussion-message
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
excludeAiDiscussions: false
port:
entity:
mappings:
identifier: .id
title: '(.type // "Message") + (if .text then ": " + .text[0:80] else "" end)'
blueprint: '"plainDiscussionMessage"'
properties:
text: .text
messageType: .type
slackMessageLink: .slackMessageLink
timestamp: .createdAt.iso8601
authorType: .createdBy.__typename
authorId: .createdBy.userId // .createdBy.customerId // .createdBy.machineUserId // .createdBy.systemId
relations:
thread: .threadId
discussion: .threadDiscussionId

Monitoring and sync status​

To learn more about how to monitor and check the sync status of your integration, see the relevant documentation.

Mapping & selectors per resource​

Use the explorer below to view sample payloads and the resulting Port entities for each resource type.

Set up live events​

This integration supports live events, allowing real-time updates to your software catalog without waiting for the next scheduled sync.

Note that different events are supported depending on the resource type.

Live events base URL

For integrations installed using the Hosted by Port method, you can obtain the live events base URL from the Get an integration API. The response includes liveEventsUuid and liveEventsIngestHostname in the appSpec section.

Call the endpoint with your integration's identifier:

curl -X GET \
'https://api.port.io/v1/integration/<INTEGRATION_IDENTIFIER>' \
-H 'Authorization: Bearer <PORT_ACCESS_TOKEN>'
Selecting a Port API URL by account region

The port_region, port.baseUrl, portBaseUrl, port_base_url and OCEAN__PORT__BASE_URL parameters select which Port API instance to use:

In the response, look for the following fields under spec.appSpec:

{
"spec": {
"appSpec": {
"liveEventsUuid": "abcdefghijk",
"liveEventsIngestHostname": "ingest.ocean.getport.io"
}
}
}

Combine them to build the live events base URL:

https://<liveEventsIngestHostname>/live-events/<liveEventsUuid>

For example, with the values above, the live events base URL is:

https://ingest.ocean.getport.io/live-events/abcdefghijk

This is the same URL configured for the integration.

Limitations​

  • Live events need an event listener that processes webhooks and a reachable liveEvents.baseUrl (or hosted-by-Port equivalent).
  • Plain does not emit dedicated company, tenant, user, or machine-user webhooks. Those kinds update when related thread or customer events fire.
  • Only the UK GraphQL host (https://core-api.uk.plain.com/graphql/v1) is known to resolve. Use apiUrl only if Plain adds another region.
  • Customer tenants come from the first page of tenantMemberships (at most 100). That selection needs customerTenantMembership:read in addition to customer:read.
  • The user query selects role, which needs roles:read in addition to user:read.
  • Thread messages come from thread.timelineEntries, which needs timeline:read in addition to thread:read.
  • Thread tier is mapped as a string (.tier.name), not a separate kind. Nested selection may need tier:read.
  • A human thread assignee maps to assignee (plainUser). A machine assignee maps to machineAssignee (plainMachineUser). assigneeType stores the Plain __typename.
  • A missing Plain permission fails that kind and logs the permission name. The other kinds still sync.