Collaboration
Post a Cortex message for an automation run, to one person's Cortex DM or to a room (a workspace channel or a workspace's General), through one governed operation
action.collaboration.post lets an automation run (a workflow, a process flow
or a live Cortex turn) send a Cortex message to one person, in that person's own
Cortex DM, or to a room: a workspace channel or a workspace's General. Cortex is
the sender, and the message carries a record of the run that posted it. The run's Run As
person or service account decides where the run may post: the platform takes
that identity, the organization and the classification from the run itself,
never from your input.
Your code must run inside an automation run: a workflow run, a process-flow run
or a live Cortex turn. A call made with a standalone bearer token, outside any
run, is refused with FORBIDDEN: run_as_required when the token names no Run
As, and not_an_automation_run otherwise. The sample shows the shape of the
call.
import { createHttpPlatformTransport, createScrydonSDK } from '@scrydon/sdk/platform'
const platform = createScrydonSDK(
createHttpPlatformTransport({
baseUrl: 'https://scrydon.com',
headers: () => ({ authorization: `Bearer ${accessToken}` }),
})
)
const { postId, status } = await platform.action.collaboration.post({
target: { kind: 'person', userId: 'user_123' },
markdown: 'Your weekly report is ready.',
idempotencyKey: 'weekly-report',
})| Operation | HTTP | What it does |
|---|---|---|
action.collaboration.post | POST /api/platform/v1/action/collaboration/post | Posts one Cortex message for the calling run. Needs tools:write. The platform never retries the call for you. |
collaboration.recipients.list | POST /api/platform/v1/collaboration/recipients/list | Lists the people or rooms a Cortex message may go to, one page at a time. Needs tools:read. It never sends anything. See List whom a step may message. |
status is published when the message is in the room, or queued when the
first send did not land. The platform then tries again, at least 30 seconds
apart and up to five attempts in all, always as the same message, so the room
shows it once. If none of the attempts lands, the post is abandoned. The call
that queued it is never told: a later call with the same idempotencyKey
usually answers PROVIDER_UNAVAILABLE, as described under Errors.
Availability
Posting needs the Cortex messaging service to run in your deployment, and Cortex
to be enabled and ready for your organization. The operation answers
CAPABILITY_NOT_CONFIGURED (HTTP 409) and posts nothing in two cases:
- The service does not run in this deployment. The error has no
details.refusal. It lasts until the deployment's administrator turns the service on, apart from a short window while a deployment starts. - The service runs, but Cortex is not enabled for your organization, or is not
ready for it yet. The error carries
details.refusalcollaboration_not_enabled. It normally lasts until Cortex is enabled for the organization, but it also appears for a short time while Cortex's connection to the organization starts.
Write your code to handle both answers.
Targets
kind | Field | Where the message lands |
|---|---|---|
person | userId | The person's own Cortex DM. The Run As is never added to it. |
channel | channelId | A workspace channel that the Run As has joined. |
workspace_general | workspaceId | The General room of that workspace, when the Run As has joined it. |
team | teamId | Not available for now. A team no longer has a room, so this target is refused as unknown_target. |
A person must be an active member of your organization whom Cortex can message.
If they have no Cortex DM yet, the platform opens one for them. A call cannot
tell an unknown person from one Cortex cannot message: both are answered
recipient_not_messageable.
A room must belong to your organization and have a Cortex room that is set up,
and the room must show the Run As as joined, so a run cannot post where its Run
As could not. A channel must also be live: one an administrator created in a
workspace and has not archived. Every kind of room refuses a Run As that has not
joined it with the same not_a_channel_member.
A service account joins the channels and the General of every workspace it belongs to, and none of any other workspace. A service account can also post to a person: Cortex sends the message into that person's own Cortex DM, and the account is in no room for that.
A message mentions nobody: @room, user ids and user links in markdown notify
no one, and a link to a person is shown as plain text.
Only an automation run can post
A call that is not part of a workflow, process-flow or Cortex-turn run is
refused with FORBIDDEN. So is a run with no Run As, such as a workflow an
extension deployed with no owner. Read details.refusal for which of the two it
was.
Idempotency
The same idempotencyKey within one run returns the first post's result and
never posts twice. The same key with different text or a different target is
refused. A key belongs to its run: the next run of the same workflow posts again
under the same key. If the first post was abandoned, the same key usually
answers that instead (or published, if its last send is later confirmed); see
Errors.
Limits
| Limit | Value |
|---|---|
markdown | At most 16,000 characters, and not blank |
idempotencyKey | 1 to 200 characters |
target.userId | 1 to 256 characters |
target.channelId | A UUID |
target.workspaceId | A UUID |
| Posts per organization | 30 per minute |
| Posts per automation and recipient | 10 per hour |
One Cortex message also has a size budget once it is sent. Text with many
multi-byte characters can reach that budget before it reaches 16,000
characters; the call is then refused as over_budget.
Governance
The text is checked before anything is posted. If your organization's
data-loss prevention would change it, the post is refused with POLICY_BLOCKED
and nothing is posted: the platform never posts a rewritten message in your
place. A post is also refused with POLICY_BLOCKED when its classification is
not covered by the room it would land in. The post is at the classification the
run asserts, or at your organization's unlabelled-data classification when the
run asserts none. Each decision, allowed or refused, is written to the audit log
before the call returns, without the text.
Only a read or write that fails stops the decision: if data-loss prevention
cannot decide, your organization's classification scheme or the room's state
cannot be read, or the audit record cannot be written, the call is a retryable
PROVIDER_UNAVAILABLE. A room with no classification label is held at your
organization's unlabelled-data classification. A room whose label was redacted
or cannot be read is held at the lowest classification of your scheme (rank 0,
PUBLIC on the default scheme; see
Classification and clearance), so a post
classified above it is refused with POLICY_BLOCKED and details.policyCode
EGRESS_BLOCKED_BY_CLEARANCE, and retrying does not change that.
Errors
Every failure is a PlatformApiError carrying a stable code. A refusal also
carries its reason in details.refusal, which is stable and safe to branch on.
| Code | HTTP | details.refusal | When |
|---|---|---|---|
FORBIDDEN | 403 | not_an_automation_run | The call is not part of a workflow, process-flow or Cortex-turn run. |
FORBIDDEN | 403 | run_as_required | The run has no Run As. |
FORBIDDEN | 403 | run_as_service_account_unavailable | The Run As service account is not enabled, no longer exists, or has no Cortex identity yet. |
FORBIDDEN | 403 | run_as_not_a_person | The Run As person is not an active member of the organization, or has not appeared in Cortex's directory of it yet. |
FORBIDDEN | 403 | recipient_not_messageable | The person is not someone Cortex can message in this organization, or does not exist. The two are not told apart. |
FORBIDDEN | 403 | not_a_channel_member | The Run As has not joined that room. Every kind of room answers with this code. |
INVALID_REQUEST | 400 | unknown_target | The room does not exist in the organization: a channel that does not exist or has been archived, or a workspace whose Cortex room is not set up or has been retired. A team target is also answered with this code, because team targets are not available for now. |
INVALID_REQUEST | 400 | over_budget | The message is too large to send as one Cortex message. |
INVALID_REQUEST | 400 | idempotency_key_reused | The key was already used in this run for different text or a different target. |
INVALID_REQUEST | 400 | none | The body fails the contract, for example blank markdown. |
CAPABILITY_NOT_CONFIGURED | 409 | collaboration_not_enabled | Cortex is not enabled or not ready for the organization; see Availability. |
CAPABILITY_NOT_CONFIGURED | 409 | channel_not_bound | The channel is live but has no Cortex room yet. A workspace_general target in that state is unknown_target. |
CAPABILITY_NOT_CONFIGURED | 409 | cortex_not_in_room | The room is missing one of Cortex's own members, so Cortex cannot post into it. |
CAPABILITY_NOT_CONFIGURED | 409 | none | The Cortex messaging service does not run in this deployment; see Availability. |
POLICY_BLOCKED | 422 | none | A governance decision, as described under Governance. details.policyCode is LLM_BLOCKED_BY_DLP when data-loss prevention refused the text, and EGRESS_BLOCKED_BY_CLEARANCE when the room's classification does not cover the post. |
PROVIDER_UNAVAILABLE | 503 | none | Cortex could not check or record the post right now: data-loss prevention could not decide, your organization's classification scheme could not be read, the room's state could not be read, the recipient or their Cortex DM could not be looked up or opened, or the audit record could not be written. retryable is true. When data-loss prevention could not decide, details.unavailable says why: policy_not_provisioned lasts until an administrator provisions a data-loss prevention policy, while policy_load_failed and dlp_scan_failed mean the policy could not be loaded or the scan failed. Retry with the same idempotencyKey: if the first call did record the post, you get that post back, not a second one. |
PROVIDER_UNAVAILABLE | 503 | none | The post was admitted, but every one of its five attempts failed, and the last may have reached the room. retryable is false and details.postId names the post. It may or may not be in the room, so check before posting again. A retry under the same idempotencyKey usually answers this again; if the send that was still running is later confirmed, it answers published instead. A new key makes a new post, which can duplicate this one. |
PROVIDER_UNAVAILABLE | 503 | none | collaboration.recipients.list could not read the Cortex directory, a store, the homeserver, or the names of your organization's workspaces and teams. retryable is true. The list never answers this with an empty page. |
RATE_LIMITED | 429 | organization_rate, source_recipient_rate | The organization or the automation-and-recipient limit was reached. retryable is true; wait retryAfter seconds. |
extension_permission_not_granted is not in this table because no client receives it. It reaches only an extension tool that calls ctx.action.collaboration.post over the platform SDK, as an Error whose message starts with extension_permission_not_granted:. The extension did not declare the cortex.messages.send permission, or the product its tool runs as is not enabled for the organization. A caller of this operation through @scrydon/sdk/platform never receives it. See Platform permissions.
import { PlatformApiError } from '@scrydon/sdk/platform'
try {
await platform.action.collaboration.post({ target, markdown, idempotencyKey })
} catch (err) {
if (err instanceof PlatformApiError && err.code === 'FORBIDDEN') {
// err.details?.refusal says why, for example 'not_a_channel_member'.
}
if (err instanceof PlatformApiError && err.code === 'PROVIDER_UNAVAILABLE' && !err.retryable) {
// err.details?.postId names a post that may or may not have been delivered.
}
}List whom a step may message
The same page also covers the read behind the workflow editor's pickers.
collaboration.recipients.list returns the people a Cortex message may go to, or
the rooms the caller has joined, one page at a time. The Person and
Channel pickers of the Cortex block use it, and
Messages from automations shows them in the editor.
It needs tools:read, never sends anything, and is safe to retry.
const page = await platform.collaboration.recipients.list({
kind: 'people',
search: 'ada',
limit: 25,
})
// page.items: [{ id: 'user_123', label: 'Ada Lovelace' }]
// page.nextCursor: a string while there may be more, otherwise null| Field | Meaning |
|---|---|
kind | people, or channels for every kind of room. |
search | Narrows the list. 1 to 128 characters once trimmed. |
limit | 1 to 50 items. 25 when omitted. |
cursor | The nextCursor of the previous page, passed back unchanged. |
ids | Looks up named recipients instead of paging; see Look up recipients you already hold. |
Each item carries an id and a label. For a person, the id is the userId
a post takes. For a room, the id is the room's key, which names its kind:
| Room | id | Target it names |
|---|---|---|
| A workspace channel | The channel's UUID | { kind: 'channel', channelId } |
| A workspace's General | general: and the workspace's UUID | { kind: 'workspace_general', workspaceId } |
parseCollaborationRoomSelection(id) from @scrydon/sdk/platform turns a room's
id into its target, and returns null for a string that names no room.
collaborationRoomSelectionKey(target) is its inverse. A channel's key is its
bare id, so a step that stored a channel before Generals were listed
still names the same channel. A stored team: key still parses; posting to it
is refused as unknown_target until organization channel targets arrive.
The label is the name to show: a person's display name, or Unnamed person
when they have none; a channel's name; and General for a workspace's General.
A person's Matrix id is never listed, and two people who share a name have the
same label and differ only by id.
An item may also carry a detail, to show beside the label. A channel and a
workspace's General carry their workspace's name. A person carries none. The
detail is English.
Who is listed
- People. The active people of your organization whom Cortex may message,
the same people a post accepts as
userId. The caller is listed, so a step can message the person who wrote it. Cortex's own agents and identities from outside your organization are left out. Listing a person does not promise that a post to them is accepted: the post checks its one recipient when it runs, and can still answerrecipient_not_messageable. - Rooms (
channels). The rooms that a post would accept, listed in this order: workspace channels, then workspaces' Generals. A channel must not be archived, and every room must have a Cortex room that is set up. The post and the list share one filter, so the list offers what a post accepts. A room is listed only when the caller has joined it and Cortex is in it. Asearchmatches a channel's name, or a General's name or its workspace's name, ignoring case.
The list is the caller's, not the run's. It is read for the person editing, while
a run later posts as its Run As. A room the caller can pick can still be refused
not_a_channel_member when the step runs, if the Run As has not joined it.
Keep paging until the cursor is null
A page can hold fewer items than limit, or none, and still carry a
nextCursor. The cursor follows the read of people or rooms, and a row that the
caller may not be offered is dropped after that read. For rooms this is common:
a page of 50 rooms may hold only the few the caller has joined. A page of rooms
can also end where one kind of room ends and the next begins. Keep requesting
pages until nextCursor is null; do not treat a short page as the end. The
order is stable, so paging never repeats or skips a room that did not change in
between.
Look up recipients you already hold
Pass ids to look up the recipients a step already stores, for example to show
the name behind a saved id. It takes at most 50 ids and cannot be combined with
search or cursor. The answer never carries a cursor: nextCursor is null.
An id the list would not offer, because it names nobody, a person Cortex cannot
message, or a room the caller has not joined, is left out of the answer, so
nothing is revealed about it. For rooms, pass the item's id (its key), not a
bare workspace id.
A service account lists nothing
The list is read as a person. A call made as a service account, with no delegated user, returns an empty page. So does a call for a person who is not in Cortex's directory yet. When a call names both a user and a service account, the list is read as the user.
Errors from the list
The list shares the Errors table above:
CAPABILITY_NOT_CONFIGUREDwithdetails.refusalcollaboration_not_enabledwhen Cortex is not enabled or not ready for the organization.CAPABILITY_NOT_CONFIGUREDwith nodetails.refusalwhen the Cortex messaging service does not run in this deployment. Both are described under Availability.PROVIDER_UNAVAILABLE, which is retryable, when the directory, a store, the homeserver, or the names of your organization's workspaces and teams could not be read. The list never answers this with an empty page, because an empty page reads as nobody to message.INVALID_REQUESTfor a body that fails the contract, such asidscombined withsearch, or acursorthat is not in the form the list issues.