assets/approval-workflow.json
{
"identifier": "deploy-production-with-approval",
"title": "Deploy to production (with approval)",
"icon": "Lock",
"description": "Require two approvals from the platform team before deploying a service to production.",
"category": "Deployments",
"nodes": [
{
"identifier": "trigger",
"config": {
"type": "SELF_SERVE_TRIGGER",
"permissions": {
"teams": ["platform-team"]
},
"userInputs": {
"properties": {
"service": {
"type": "string",
"format": "entity",
"blueprint": "service",
"title": "Service"
},
"version": {
"type": "string",
"title": "Version"
}
},
"required": ["service", "version"],
"order": ["service", "version"]
}
}
},
{
"identifier": "approval",
"title": "Approve production deploy",
"config": {
"type": "INPUT",
"description": "Approve or decline this production deployment.",
"userInputs": {
"properties": {
"reason": {
"type": "string",
"title": "Reason",
"description": "The reason for the approve/decline."
}
},
"buttons": [
{ "identifier": "approve", "label": "Approve", "variant": "PRIMARY" },
{ "identifier": "decline", "label": "Decline", "variant": "DANGER" }
]
},
"outlets": [
{ "evaluationMethod": "button", "identifier": "approve", "title": "Approve", "numOfResponders": 2 },
{ "evaluationMethod": "button", "identifier": "decline", "title": "Decline", "numOfResponders": 1 }
],
"responders": {
"users": ["alice@example.com", "bob@example.com", "carol@example.com"]
}
}
},
{
"identifier": "deploy",
"title": "Deploy to production",
"config": {
"type": "WEBHOOK",
"url": "https://api.example.com/deploy",
"method": "POST",
"body": {
"service": "{{ .outputs.trigger.service }}",
"version": "{{ .outputs.trigger.version }}",
"environment": "production"
}
}
},
{
"identifier": "notify_declined",
"title": "Notify decline",
"config": {
"type": "WEBHOOK",
"url": "{{ .secrets[\"slack-webhook-url\"] }}",
"method": "POST",
"onFailure": "continue",
"body": {
"text": "Production deploy of {{ .outputs.trigger.service }} v{{ .outputs.trigger.version }} was declined. Reason: {{ .outputs.approval.responses[0].inputs.reason }}"
}
}
}
],
"connections": [
{ "sourceIdentifier": "trigger", "targetIdentifier": "approval" },
{ "sourceIdentifier": "approval", "targetIdentifier": "deploy", "sourceOutletIdentifier": "approve" },
{ "sourceIdentifier": "approval", "targetIdentifier": "notify_declined", "sourceOutletIdentifier": "decline" }
]
}
assets/event-driven-ai-incident-response.json
{
"identifier": "ai-incident-summary",
"title": "AI incident summary on degradation",
"icon": "Alert",
"description": "When a critical service degrades, use AI to draft an incident summary and open an incident record.",
"category": "Incident response",
"nodes": [
{
"identifier": "trigger",
"config": {
"type": "EVENT_TRIGGER",
"event": {
"type": "ENTITY_UPDATED",
"blueprintIdentifier": "service"
},
"condition": {
"type": "JQ",
"expressions": [
".diff.after.properties.tier == \"critical\"",
".diff.before.properties.healthStatus == \"healthy\"",
".diff.after.properties.healthStatus != \"healthy\""
],
"combinator": "and"
}
}
},
{
"identifier": "summarize",
"title": "Generate incident summary",
"config": {
"type": "AI",
"userPrompt": "Service {{ .outputs.trigger.diff.after.title }} has degraded from {{ .outputs.trigger.diff.before.properties.healthStatus }} to {{ .outputs.trigger.diff.after.properties.healthStatus }}. Analyze the service and its dependencies and produce a concise incident summary with potential root causes.",
"systemPrompt": "You are an incident response analyst. Provide structured incident summaries with severity assessment, affected components, and recommended next steps.",
"tools": ["list_blueprints", "list_entities"],
"outputSchema": {
"type": "object",
"properties": {
"summary": { "type": "string" },
"severity": { "type": "string" },
"rootCause": { "type": "string" }
},
"required": ["summary", "severity"]
}
}
},
{
"identifier": "create_incident",
"title": "Create incident record",
"config": {
"type": "UPSERT_ENTITY",
"blueprintIdentifier": "incident",
"mapping": {
"identifier": "incident-{{ .outputs.trigger.diff.after.identifier }}-{{ now | todateiso8601 }}",
"title": "Degradation: {{ .outputs.trigger.diff.after.title }}",
"properties": {
"summary": "{{ .outputs.summarize.response | fromjson | .summary }}",
"severity": "{{ .outputs.summarize.response | fromjson | .severity }}",
"status": "open",
"detectedAt": "{{ now | todateiso8601 }}"
},
"relations": {
"service": "{{ .outputs.trigger.diff.after.identifier }}"
}
}
}
},
{
"identifier": "notify_slack",
"title": "Notify on-call",
"config": {
"type": "WEBHOOK",
"url": "{{ .secrets[\"slack-webhook-url\"] }}",
"method": "POST",
"onFailure": "continue",
"body": {
"text": "Incident opened for {{ .outputs.trigger.diff.after.title }} (severity: {{ .outputs.summarize.response | fromjson | .severity }}).\n{{ .outputs.summarize.response | fromjson | .summary }}"
}
}
}
],
"connections": [
{ "sourceIdentifier": "trigger", "targetIdentifier": "summarize" },
{ "sourceIdentifier": "summarize", "targetIdentifier": "create_incident" },
{ "sourceIdentifier": "create_incident", "targetIdentifier": "notify_slack" }
]
}
assets/self-service-deploy-workflow.json
{
"identifier": "deploy-service",
"title": "Deploy service",
"icon": "Deployment",
"description": "Deploy a service to an environment, using a canary rollout for production.",
"category": "Deployments",
"allowAnyoneToViewRuns": true,
"nodes": [
{
"identifier": "trigger",
"config": {
"type": "SELF_SERVE_TRIGGER",
"permissions": {
"roles": ["Member"]
},
"userInputs": {
"properties": {
"service": {
"type": "string",
"format": "entity",
"blueprint": "service",
"title": "Service"
},
"version": {
"type": "string",
"title": "Version",
"description": "Git tag or commit SHA to deploy."
},
"environment": {
"type": "string",
"title": "Environment",
"enum": ["staging", "production"]
}
},
"required": ["service", "version", "environment"],
"order": ["service", "version", "environment"]
}
}
},
{
"identifier": "check_env",
"title": "Check environment",
"config": {
"type": "CONDITION",
"outlets": [
{
"identifier": "prod",
"title": "Production",
"expression": ".outputs.trigger.environment == \"production\""
}
]
}
},
{
"identifier": "deploy_prod",
"title": "Deploy (canary)",
"config": {
"type": "WEBHOOK",
"url": "https://api.example.com/deploy",
"method": "POST",
"body": {
"service": "{{ .outputs.trigger.service }}",
"version": "{{ .outputs.trigger.version }}",
"environment": "production",
"canary": true
}
}
},
{
"identifier": "deploy_other",
"title": "Deploy",
"config": {
"type": "WEBHOOK",
"url": "https://api.example.com/deploy",
"method": "POST",
"body": {
"service": "{{ .outputs.trigger.service }}",
"version": "{{ .outputs.trigger.version }}",
"environment": "{{ .outputs.trigger.environment }}"
}
}
},
{
"identifier": "update_entity",
"title": "Record last deployment",
"config": {
"type": "UPSERT_ENTITY",
"blueprintIdentifier": "service",
"mapping": {
"identifier": "{{ .outputs.trigger.service }}",
"properties": {
"lastDeployedVersion": "{{ .outputs.trigger.version }}",
"lastDeployedEnvironment": "{{ .outputs.trigger.environment }}",
"lastDeployedAt": "{{ now | todateiso8601 }}"
}
},
"onFailure": "continue"
}
},
{
"identifier": "notify_slack",
"title": "Notify Slack",
"config": {
"type": "WEBHOOK",
"url": "{{ .secrets[\"slack-webhook-url\"] }}",
"method": "POST",
"onFailure": "continue",
"body": {
"text": "Deployed {{ .outputs.trigger.service }} version {{ .outputs.trigger.version }} to {{ .outputs.trigger.environment }}, triggered by {{ .workflowRun.trigger.by.email }}."
}
}
}
],
"connections": [
{ "sourceIdentifier": "trigger", "targetIdentifier": "check_env" },
{ "sourceIdentifier": "check_env", "targetIdentifier": "deploy_prod", "sourceOutletIdentifier": "prod" },
{ "sourceIdentifier": "check_env", "targetIdentifier": "deploy_other", "fallback": true },
{ "sourceIdentifier": "deploy_prod", "targetIdentifier": "update_entity" },
{ "sourceIdentifier": "deploy_other", "targetIdentifier": "update_entity" },
{ "sourceIdentifier": "update_entity", "targetIdentifier": "notify_slack" }
]
}
references/data-flow.md
# Data flow reference
Every `{{ ... }}` field in a workflow JSON is a JQ expression, evaluated at
runtime. Use JQ syntax only, not any other templating language.
## Referencing previous node outputs
```
{{ .outputs["<node_identifier>"].<field> }}
```
Bracket notation (`.outputs["node-id"]`) always works, including when a node
identifier contains hyphens or other special characters. Dot notation
(`.outputs.node_id.field`) is more compact but **breaks silently on
hyphens**, JQ reads `my-node` as subtraction. If you use dot notation
anywhere, keep node identifiers snake_case (`fetch_service`, not
`fetch-service`) so it stays safe; otherwise use bracket notation
everywhere.
`.outputs.trigger` is always aliased to whichever trigger node actually fired
this run, regardless of that node's real identifier. Use it instead of the
literal trigger identifier, especially in workflows with multiple trigger
nodes:
```
{{ .outputs.trigger.serviceName }}
{{ .outputs.trigger.diff.after.properties.status }}
```
| What | Pattern |
|---|---|
| Self-service input | `{{ .outputs.trigger.inputName }}` |
| Event entity (after) | `{{ .outputs.trigger.diff.after.properties.x }}` |
| Event entity (before) | `{{ .outputs.trigger.diff.before.properties.x }}` |
| Event action | `{{ .outputs.trigger.action }}` |
| Webhook response | `{{ .outputs["node_id"].response.data }}` |
| Upsert entity response | `{{ .outputs["node_id"].response.data }}` |
| AI / AI agent response | `{{ .outputs["node_id"].response }}` |
| Secrets | `{{ .secrets["secret-name"] }}` |
| Current time | `{{ now \| todateiso8601 }}` |
## Variables
A node's `variables` block reshapes its output **after** it runs, useful for
extracting one field, dropping sensitive data, or combining the node's own
result with earlier outputs.
Setting `variables` **replaces the node's entire default output.** Anything
downstream still needs (like `response`) must be re-declared explicitly.
Inside `variables` JQ expressions you have access to:
| Context | Description |
|---|---|
| `.result` (or `.result.response...`) | The current node's raw output, before `variables` is applied. |
| `.outputs["<node_identifier>"]` | Outputs from any previous node, same rules as above. |
```json
{
"identifier": "fetch_entity",
"config": {
"type": "WEBHOOK",
"url": "https://api.port.io/v1/blueprints/service/entities/search",
"method": "POST",
"body": {
"query": { "combinator": "and", "rules": [{ "property": "$identifier", "operator": "=", "value": "{{ .outputs.trigger.serviceId }}" }] }
}
},
"variables": {
"entity": "{{ .result.response.data.entities[0] }}",
"response": "{{ .result.response }}",
"triggerInput": "{{ .outputs.trigger.serviceId }}"
}
}
```
Access it downstream as `{{ .outputs.fetch_entity.entity.title }}`. Since
`variables` was set, only `entity`, `response`, and `triggerInput` exist on
this node's output, the default `response.data` shape is gone unless you
re-declared it (as `response` is, above).
## Fetching context lake data from within a workflow
Query entities with `POST https://api.port.io/v1/blueprints/<blueprint>/entities/search`
from a `WEBHOOK` node, no `Authorization` header needed, then extract the
result with `variables`:
```json
{
"identifier": "fetch_service",
"config": {
"type": "WEBHOOK",
"url": "https://api.port.io/v1/blueprints/service/entities/search",
"method": "POST",
"body": {
"query": { "combinator": "and", "rules": [{ "property": "$identifier", "operator": "=", "value": "{{ .outputs.trigger.diff.after.relations.service }}" }] }
}
},
"variables": { "entity": "{{ .result.response.data.entities[0] }}" }
}
```
Chain multiple lookups the same way to traverse relations (fetch a
deployment's environment, then that environment's cluster, and so on),
feeding each `WEBHOOK`'s `body` from the previous one's `variables`.
## Secrets
```
{{ .secrets["my-api-key"] }}
```
```json
{ "headers": { "Authorization": "Bearer {{ .secrets[\"api-token\"] }}" } }
```
## Workflow run context
Available in every node, useful for tracing, audit logs, and correlating
external system calls with a specific run.
| What | Pattern |
|---|---|
| Workflow identifier | `{{ .workflow.identifier }}` |
| Workflow version | `{{ .workflow.versionIdentifier }}` |
| Run identifier (`wfr_...`) | `{{ .workflowRun.identifier }}` |
| Run created at | `{{ .workflowRun.createdAt }}` |
| Trigger node identifier | `{{ .workflowRun.trigger.identifier }}` |
| Triggering user email | `{{ .workflowRun.trigger.by.email }}` |
| Triggering user first/last name | `{{ .workflowRun.trigger.by.firstName }}` / `{{ .workflowRun.trigger.by.lastName }}` |
| Node run identifier (`wfnr_...`) | `{{ .workflowNodeRun.identifier }}` |
| Node run created at | `{{ .workflowNodeRun.createdAt }}` |
`workflowNodeRun` is specific to whichever node is currently executing and
changes per node.
## JQ helpers commonly used in templates
```
{{ .name | ascii_downcase }} // lowercase
{{ .name | gsub(" "; "-") }} // replace spaces
{{ if .x then "yes" else "no" end }} // conditional
{{ .status // "default" }} // default if null
{{ .value | tostring }} // stringify (GitHub Actions inputs must be strings)
{{ now | todateiso8601 }} // current time, ISO 8601
```
## Error output on a failed node
When a node fails (and `onFailure: "continue"` lets the run proceed), its
output includes `error.message`:
```
{{ .outputs["node_id"].error.message }}
```
references/node-types.md
# Node types reference
Every node has an `identifier`, an optional `title`/`icon`/`description`
(action and flow nodes only, see below), and a type-specific `config`. Action
nodes also accept optional `variables` (reshape the node's output, see
[data-flow.md](data-flow.md)), `links` (up to 3 URL templates shown on the
node run), and `verbose` (log full request/response bodies, off by default).
Don't set `title`, `icon`, or `description` on trigger nodes, they're not
used there.
## Trigger nodes
### `SELF_SERVE_TRIGGER`
Runs on demand when a user submits a form.
```json
{
"identifier": "trigger",
"config": {
"type": "SELF_SERVE_TRIGGER",
"permissions": { "roles": ["Member"] },
"userInputs": {
"properties": {
"service": {
"type": "string",
"format": "entity",
"blueprint": "service",
"title": "Service"
},
"environment": {
"type": "string",
"enum": ["staging", "production"],
"title": "Environment"
}
},
"required": ["service", "environment"],
"order": ["service", "environment"]
}
}
}
```
| Field | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userInputs.properties` | Form fields. Types: `string`, `number`, `boolean`, `object`, `array`. String formats: `entity` (needs `blueprint`), `team`, `user`, `url`, `email`, `date-time`, `yaml`. |
| `userInputs.required` | Required field keys. |
| `permissions` | Who can see and execute this trigger. See [permissions.md](permissions.md). Omitted = Admin only. |
| `contexts` | Optional array surfacing the trigger beyond the self-service page. `{ "on": "CREATE_ENTITY", "blueprintIdentifier": "..." }` replaces a blueprint's default entity-creation form. `{ "on": "ENTITY", "userInput": "..." }` adds the trigger to the bolt (⚡) menu on matching entities, pre-filling that input; the referenced `userInput` must be an entity-format field, and each `ENTITY` context must reference a different input. |
| `variant` | `"DEFAULT"` (omit) or `"ALERT"` (red, destructive styling in the bolt menu, for deletions/rollbacks). Presentation only. |
| `published` | Default `true`. Set `false` to disable the trigger without deleting it. |
Outputs: user inputs are stored directly, `{{ .outputs.trigger.service }}`.
`category` (grouping label) is set on the **workflow** object, not the
trigger node, and applies to all of the workflow's self-service triggers.
### `EVENT_TRIGGER`
Runs automatically when a context lake entity changes.
```json
{
"identifier": "trigger",
"config": {
"type": "EVENT_TRIGGER",
"event": { "type": "ENTITY_UPDATED", "blueprintIdentifier": "service" },
"condition": {
"type": "JQ",
"expressions": [".diff.after.properties.status == \"deployed\""],
"combinator": "and"
}
}
}
```
| Field | Description |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event.type` | `ENTITY_CREATED`, `ENTITY_UPDATED`, `ENTITY_DELETED`, `ANY_ENTITY_CHANGE`, or `TIMER_EXPIRED`. |
| `event.blueprintIdentifier` | Blueprint to watch. |
| `event.propertyIdentifier` | Required for `TIMER_EXPIRED`: the timer property to watch. |
| `condition` | Optional. `type: "JQ"`, `expressions` (array, evaluated against the event payload directly, not through `.outputs.trigger`), `combinator` (`"and"`/`"or"`). |
| `published` | Default `true`. Set `false` to stop the trigger from responding to events without deleting it. |
Event data varies by event type:
| Event | `diff.before` | `diff.after` |
| ---------------- | ---------------------- | --------------------- |
| `ENTITY_CREATED` | `null` | entity after creation |
| `ENTITY_UPDATED` | entity before | entity after |
| `ENTITY_DELETED` | entity before deletion | `null` |
Outputs: `{{ .outputs.trigger.action }}` (`CREATE`/`UPDATE`/`DELETE`),
`{{ .outputs.trigger.diff.after.identifier }}`,
`{{ .outputs.trigger.diff.after.properties.x }}`,
`{{ .outputs.trigger.diff.before.properties.x }}`. Take event data from
`diff`, not from a top-level `event` field.
## Action nodes
### `WEBHOOK`
Sends an HTTP request. The most versatile action, use it for anything that
doesn't have a dedicated node type.
```json
{
"identifier": "call_api",
"config": {
"type": "WEBHOOK",
"url": "https://api.example.com/endpoint",
"method": "POST",
"headers": { "Authorization": "Bearer {{ .secrets[\"token\"] }}" },
"body": { "name": "{{ .outputs.trigger.name }}" },
"synchronized": true,
"onTimeout": "fail",
"onFailure": "terminate"
}
}
```
| Field | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | Required. |
| `method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. Default `POST`. |
| `headers`, `body` | Optional. |
| `agent` | Route the request through the [Port Execution Agent](https://docs.port.io/workflows/build-workflows/action-nodes/port-execution-agent) to reach a private network. Default `false`. |
| `synchronized` | Default `true`: the node waits for the response, which becomes `response.data`/`response.status`. Set `false` to fire-and-forget; the external system must then call Port's API to mark the node run complete. |
| `onTimeout` | `"fail"` (default) or `"continue"` (mark the node successful with `{ timeout: true }` if synchronous, or leave it in progress for a callback if asynchronous). |
| `onFailure` | `"terminate"` (default, stops the run) or `"continue"`. |
Response: `{{ .outputs.call_api.response.data }}`,
`{{ .outputs.call_api.response.status }}`.
Calling `https://api.port.io` needs no `Authorization` header, Port
auto-authenticates it. Entity routes are blueprint-scoped:
`GET /v1/blueprints/{blueprint}/entities/{entity}` (single),
`GET /v1/blueprints/{blueprint}/entities` (list),
`POST /v1/blueprints/{blueprint}/entities/search` (query). Not
`/v1/entities?blueprint=...`. For creating or updating context lake entities,
prefer `UPSERT_ENTITY` below over a raw webhook, it's simpler.
### `UPSERT_ENTITY`
Creates or updates a Port context lake entity. Updates only touch the fields you
specify.
```json
{
"identifier": "create_entity",
"config": {
"type": "UPSERT_ENTITY",
"blueprintIdentifier": "service",
"mapping": {
"identifier": "{{ .outputs.trigger.serviceName }}",
"title": "{{ .outputs.trigger.serviceName }}",
"team": "{{ .outputs.trigger.owningTeam }}",
"properties": { "status": "active" },
"relations": { "owner": "{{ .outputs.trigger.owner }}" }
},
"onFailure": "terminate"
}
}
```
| Field | Description |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `blueprintIdentifier` | Required. |
| `mapping.identifier` | If omitted, Port auto-generates one. |
| `mapping.title`, `mapping.icon` | Optional. |
| `mapping.team` | String or array of team identifiers. |
| `mapping.properties`, `mapping.relations` | Objects keyed by property/relation identifier. A multi-relation takes an array, or a JQ expression that evaluates to one (`"{{ .outputs.trigger.dependencies \| fromjson }}"`). |
| `onFailure` | `"terminate"` (default) or `"continue"`. |
Response: `{{ .outputs.create_entity.response.data }}`.
### `INTEGRATION_ACTION`
Dispatches an action through an installed Port integration. GitHub, GitLab,
and Azure DevOps are supported; use this instead of hand-rolling a `WEBHOOK`
call to those providers, it gets you built-in auth and status reporting.
```json
{
"identifier": "trigger_gh",
"config": {
"type": "INTEGRATION_ACTION",
"installationId": "github-integration-id",
"integrationProvider": "github-ocean",
"integrationInvocationType": "dispatch_workflow",
"integrationActionExecutionProperties": {
"org": "my-org",
"repo": "my-repo",
"workflow": "deploy.yml",
"workflowInputs": { "environment": "{{ .outputs.trigger.env }}" },
"reportWorkflowStatus": true
},
"onFailure": "terminate"
}
}
```
| Field | Description |
| -------------------------------------- | -------------------------------------------------------------------------------- |
| `installationId` | Required. The integration installation ID. |
| `integrationProvider` | Required. Provider identifier, e.g. `"github-ocean"`, `"claude-managed-agents"`. |
| `integrationInvocationType` | Required. The action to invoke on the provider. |
| `integrationActionExecutionProperties` | Provider- and action-specific properties. |
Do not use a hypothetical `GITHUB_WORKFLOW` type, GitHub dispatches go
through `INTEGRATION_ACTION`.
#### GitHub (`github-ocean`)
Set `integrationInvocationType: "dispatch_workflow"`.
| Property | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `org`/`repo`/`workflow` | Required: org/user, repo, and the workflow filename (must accept `workflow_dispatch`) or ID. |
| `workflowInputs` | Object of inputs. GitHub Actions inputs are always strings, use `\| tostring` on non-string JQ values. |
| `reportWorkflowStatus` | When `true`, Port monitors the triggered run and updates the node's status when it finishes. **Not** `reportStatus`. |
#### Claude managed agents (`claude-managed-agents`)
Requires the [Claude Managed Agents integration](https://docs.port.io/agent-management/external-agents/claude-managed-agents) installed in your Port organization.
| `integrationInvocationType` | Description |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `create_agent` | Creates a new Claude managed agent and registers it in the catalog. |
| `trigger_agent` | Starts a new session or continues an existing idle session, then sends a prompt to the agent. |
| `update_agent` | Updates an existing agent's name, model, system prompt, or config. At least one field besides `agentId` must be set. |
| `archive_agent` | Permanently archives an agent. Irreversible. |
| `create_environment` | Creates a new Claude managed environment and registers it in the catalog. |
| `update_environment` | Updates an existing environment's name, description, scope, or config. At least one field besides `environmentId` must be set. |
| `delete_environment` | Permanently hard-deletes a Claude managed environment and removes it from the catalog. Irreversible. |
**`create_agent` properties**
| Property | Description |
| -------------- | ---------------------------------------------------------------------------------------------------------- |
| `name` | Required. Agent name. |
| `model` | Required. Claude model ID (e.g. `"claude-sonnet-4-5"`). |
| `systemPrompt` | Optional. The agent's system prompt. |
| `config` | Optional. Object passed to the Anthropic agents API (e.g. `tools`, `mcp_servers`, `multiagent`, `skills`). |
**`trigger_agent` properties**
| Property | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentId` | Required. The agent to trigger. |
| `environmentId` | Required when starting a new session. Ignored when `sessionId` is provided. |
| `prompt` | Required. The user message to send. |
| `sessionId` | An existing idle session to continue. When provided, `environmentId` and `config` are ignored. |
| `reportSessionStatus` | When `true`, the node waits for the session to reach a terminal state via webhook. Requires a webhook configured on the integration. Defaults to `false`. |
| `config` | Optional. Session creation parameters (vault IDs, resources, metadata). Ignored when `sessionId` is provided. |
**`update_agent` properties**
| Property | Description |
| -------------- | ------------------------------------------------ |
| `agentId` | Required. The agent to update. |
| `name` | Optional. New agent name. |
| `model` | Optional. New model ID. |
| `systemPrompt` | Optional. New system prompt. |
| `config` | Optional. Object merged into the agent's config. |
At least one of `name`, `model`, `systemPrompt`, or `config` must be set.
**`archive_agent` properties**
| Property | Description |
| --------- | ------------------------------- |
| `agentId` | Required. The agent to archive. |
**`create_environment` properties**
| Property | Description |
| ------------- | ------------------------------------------------------------------------------------ |
| `name` | Required. Environment name (1–256 characters). |
| `description` | Optional. Human-readable description. |
| `scope` | Optional. Scope identifier (e.g. an organisation ID). |
| `config` | Optional. Object passed to the Anthropic environments API (e.g. container settings). |
Node output: `environmentId` — the ID of the newly created environment. Pass it to a `trigger_agent` node to start a session.
**`update_environment` properties**
| Property | Description |
| --------------- | ------------------------------------------------------------------------------------ |
| `environmentId` | Required. The environment to update. |
| `name` | Optional. New environment name. |
| `description` | Optional. New description. |
| `scope` | Optional. New scope identifier. |
| `config` | Optional. Object merged into the environment's config. |
At least one of `name`, `description`, `scope`, or `config` must be set.
Node output: `environmentId` — echoed back for use in downstream nodes.
**`delete_environment` properties**
| Property | Description |
| --------------- | ---------------------------------------------- |
| `environmentId` | Required. The environment to delete. |
Concurrent deletes on the same environment are serialized automatically.
```json
{
"identifier": "trigger_claude",
"config": {
"type": "INTEGRATION_ACTION",
"installationId": "my-claude-installation-id",
"integrationProvider": "claude-managed-agents",
"integrationInvocationType": "trigger_agent",
"integrationActionExecutionProperties": {
"agentId": "{{ .outputs.trigger.agentId.identifier }}",
"environmentId": "{{ .outputs.trigger.environmentId.identifier }}",
"prompt": "{{ .outputs.trigger.prompt }}",
"reportSessionStatus": true
},
"onFailure": "terminate"
}
}
```
### `KAFKA`
Publishes a message to your organization's Port-managed Kafka topic
(`{orgId}.runs`) for your own consumers to process. Requires a dedicated
Kafka topic provisioned by Port support.
```json
{
"identifier": "publish",
"config": {
"type": "KAFKA",
"payload": {
"event": "deployed",
"service": "{{ .outputs.trigger.service }}"
},
"onFailure": "terminate"
}
}
```
`payload` (required) is an object or array; it's delivered to your consumer
wrapped with workflow run metadata. Have the consumer report back with
`PATCH https://api.port.io/v1/workflows/nodes/runs/{node_run_id}`
(`status`, `result`, `output`).
### `CURSOR_AGENT`
Launches a [Cursor cloud agent](https://cursor.com/docs/cloud-agent) against
a GitHub repository or PR. The workflow pauses until the agent finishes.
```json
{
"identifier": "cursor_task",
"config": {
"type": "CURSOR_AGENT",
"apiKey": "{{ .secrets[\"CURSOR_API_KEY\"] }}",
"prompt": {
"text": "Fix the failing tests in {{ .outputs.trigger.service }}"
},
"source": {
"repository": "https://github.com/my-org/my-repo",
"ref": "main"
},
"target": { "autoCreatePr": true }
}
}
```
| Field | Description |
| ------------------------------------------ | -------------------------------------------------------------------------------------- |
| `apiKey` | Required. Store the Cursor API key as a Port secret and reference it, don't inline it. |
| `prompt.text` | Required. Task instructions. |
| `source.repository` or `source.prUrl` | One required. `prUrl` ignores `repository`/`ref`. |
| `source.ref` | Branch, tag, or commit to use as the base. |
| `model` | E.g. `"claude-sonnet-4-5"`. Omit for the default model. |
| `target.autoCreatePr`, `target.branchName` | Optional. |
Outputs (camelCase): `{{ .outputs.cursor_task.agentId }}`,
`{{ .outputs.cursor_task.status }}` (`FINISHED`/`ERROR`),
`{{ .outputs.cursor_task.summary }}`,
`{{ .outputs.cursor_task.target.prUrl }}`,
`{{ .outputs.cursor_task.target.branchName }}`.
### `AI_AGENT` and `AI`
Both invoke [Port AI](https://docs.port.io/ai-interfaces/port-ai/overview)
and pause the workflow until the invocation completes. `AI_AGENT` calls a
pre-configured agent by identifier (its tools and system prompt live on the
agent); `AI` is a general-purpose invocation you configure inline.
```json
{
"identifier": "analyze",
"config": {
"type": "AI_AGENT",
"agentIdentifier": "incident-response-agent",
"userPrompt": "Analyze the health of service {{ .outputs.trigger.service }}"
}
}
```
```json
{
"identifier": "summarize",
"config": {
"type": "AI",
"userPrompt": "Summarize the status of service {{ .outputs.trigger.service }}",
"systemPrompt": "You are a concise status reporter.",
"tools": ["list_blueprints", "list_entities"]
}
}
```
| Field | `AI_AGENT` | `AI` |
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentIdentifier` | Required | - |
| `userPrompt` | Required | Required |
| `systemPrompt` | - | Optional |
| `tools` | Configured on the agent | Array of exact tool names or regex patterns (e.g. `"sentry_.*"`); defaults to all native tools plus all `mcpServers` tools, but prefer an explicit list in workflows. See the [available tools list](https://docs.port.io/ai-interfaces/port-ai/overview?execution-modes=manual#available-tools). |
| `mcpServers` | Optional (up to 5) | Optional (up to 5), `[{ "identifier": "<_mcp_server entity id>" }]` |
| `provider`, `model` | Both set or both omitted | Both set or both omitted |
| `outputSchema` | Optional | Optional |
Response: `{{ .outputs.node_id.response }}`,
`{{ .outputs.node_id.invocationIdentifier }}`.
**Structured output**: an AI node can report success while emitting
free-form text, and a downstream `UPSERT_ENTITY` will then silently do
nothing. Set `outputSchema` (JSON Schema, `type: "object"`, only fields the
next node actually needs) so the AI emits JSON instead:
```json
"outputSchema": {
"type": "object",
"properties": {
"identifier": { "type": "string" },
"title": { "type": "string" },
"severity": { "type": "string" }
},
"required": ["identifier", "title", "severity"]
}
```
With `outputSchema` set, `response` holds the JSON object as a string, parse
it downstream: `{{ .outputs.analyze.response | fromjson | .severity }}`.
If a prompt uses `list_entities` with `include`, add `list_blueprints` to
`tools` and instruct the agent to read the schema first: `include` needs the
property's schema key (e.g. `affected_service`), not its display title
(`"Affected Service"`), and a guessed key fails the whole call.
## Flow control nodes
### `CONDITION`
Branches on JQ expressions, evaluated in order; the first matching outlet's
path is taken, otherwise the `fallback` connection (if any) runs.
```json
{
"identifier": "check_env",
"config": {
"type": "CONDITION",
"outlets": [
{
"identifier": "prod",
"title": "Production",
"expression": ".outputs.trigger.environment == \"production\""
}
]
}
}
```
```json
"connections": [
{ "sourceIdentifier": "trigger", "targetIdentifier": "check_env" },
{ "sourceIdentifier": "check_env", "targetIdentifier": "deploy_prod", "sourceOutletIdentifier": "prod" },
{ "sourceIdentifier": "check_env", "targetIdentifier": "deploy_other", "fallback": true }
]
```
Every outgoing connection from a `CONDITION` node must set
`sourceOutletIdentifier` (matching an outlet's `identifier`) or
`fallback: true`. An outlet can also set `statusLabel`/`workflowStatusLabel`
to tag the node/run when that branch is taken.
### `INPUT`
Pauses the workflow for one or more users to respond, then branches on which
button they pressed and how many responded. Use it for approvals and other
human-in-the-loop gates.
```json
{
"identifier": "inputNode",
"title": "Approve/Decline",
"config": {
"type": "INPUT",
"description": "Approve or decline the production deployment.",
"userInputs": {
"properties": {
"reason": {
"type": "string",
"title": "Reason",
"description": "The reason for the approve/decline."
}
},
"buttons": [
{ "identifier": "approve", "label": "Approve", "variant": "PRIMARY" },
{ "identifier": "decline", "label": "Decline", "variant": "DANGER" }
]
},
"outlets": [
{
"evaluationMethod": "button",
"identifier": "approve",
"title": "Approve",
"numOfResponders": 2
},
{
"evaluationMethod": "button",
"identifier": "decline",
"title": "Decline",
"numOfResponders": 1
}
],
"responders": { "users": ["alice@example.com", "bob@example.com"] }
}
}
```
| Field | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `userInputs.properties` | Form fields shown to responders, same schema as self-service `userInputs`. |
| `userInputs.buttons` | Submit buttons: `identifier`, `label`, `variant` (`PRIMARY`/`SECONDARY`/`DANGER`). |
| `outlets` | One per branch: `evaluationMethod: "button"`, `identifier` (must match a button), `numOfResponders` (positive integer). Port tallies responses per button and takes the first outlet whose threshold is met. |
| `responders.users` | Emails of who is notified and allowed to respond. Each responder can respond once. |
| `notifications` | Optional array of `email` (with `fields`) or `webhook` (`url`, `method`, `headers`, `body`) targets, notified in addition to `responders`. |
Connections need `sourceOutletIdentifier` just like `CONDITION`:
```json
"connections": [
{ "sourceIdentifier": "inputNode", "targetIdentifier": "production-deploy", "sourceOutletIdentifier": "approve" },
{ "sourceIdentifier": "inputNode", "targetIdentifier": "sendSlack", "sourceOutletIdentifier": "decline" }
]
```
Outputs: `{{ .outputs["inputNode"].selectedOutlet.identifier }}`,
`{{ .outputs["inputNode"].responses[0].inputs.reason }}`,
`{{ .outputs["inputNode"].responses[0].submitterInfo.submittedBy }}`.
## Connection rules that apply to every node
- Trigger nodes can only be a connection's `sourceIdentifier`, never its
`targetIdentifier`.
- Fan-out is not supported: a node (and each of its outlets) can have at
most one outgoing connection. Chain nodes sequentially instead of branching
a single non-`CONDITION`/`INPUT` node to two targets.
- `CONDITION` and `INPUT` connections must set `sourceOutletIdentifier` or
`fallback: true`; every other connection just needs `sourceIdentifier` and
`targetIdentifier`.
references/permissions.md
# Permissions reference
Permissions control who can see and execute a `SELF_SERVE_TRIGGER` node from
Port's self-service page; a workflow a user can't execute is hidden from
them entirely. Event triggers have no `permissions` field, they run under
the organization's automation identity. Admins can always execute every
workflow regardless of this configuration.
Set `permissions` inside the trigger node's `config`:
```json
{
"config": {
"type": "SELF_SERVE_TRIGGER",
"permissions": { "roles": ["Member"], "users": [], "teams": [] },
"userInputs": { "properties": {} }
}
}
```
## Default behavior
| `permissions` value | Who can execute |
|---|---|
| Not set, or `{}` | Admin only |
| `{ "roles": ["Member"] }` | Admin + Member |
| `{ "users": [...] }` | Admin + listed users |
| `{ "teams": [...] }` | Admin + listed teams |
| `{ "policy": {...} }` | Admin + users matching the policy |
`users` takes Port user IDs, not emails.
**Evaluation order**: static fields (`roles`, `users`, `teams`) are checked
first, any match grants access via OR logic. `policy` is only evaluated for
users that don't match a static field, it's an *additional* access path, not
a further restriction. Machine tokens bypass static checks entirely when no
`policy` is present; if a `policy` is defined, machine tokens are evaluated
against it.
```json
{ "permissions": { "roles": ["Member"], "policy": { "combinator": "and", "rules": [{ "property": { "context": "user", "property": "department" }, "operator": "=", "value": "engineering" } ] } } }
```
Here, Member role holders execute unconditionally; everyone else must be in
engineering.
## Static assignment
```json
{
"permissions": {
"roles": ["Member"],
"users": ["user-id-1", "user-id-2"],
"teams": ["platform-team", "sre-team"]
}
}
```
Multiple values in the same field are OR'd (any listed team is enough).
## Dynamic permissions (`policy`)
```json
{
"policy": {
"combinator": "and",
"rules": [
{ "property": { "context": "<context>", "property": "<property>" }, "operator": "<operator>", "value": "<value>" }
]
}
}
```
### Contexts
| Context | Resolves to |
|---|---|
| `user` | The executing user's properties, from the `_user` blueprint. Includes meta fields `$identifier` (email), `$title`, `port_role` (`"Admin"`/`"Member"`), plus any custom `_user` property. |
| `userTeams` | The executing user's team memberships, from the `_team` blueprint. Resolves to an **array** across all the user's teams (e.g. `userTeams.$identifier` is an array of team identifiers). |
| `form` | The trigger's form input values. |
### Dot notation for entity-format form inputs
| Syntax | Resolves to | Type |
|---|---|---|
| `inputName` | Raw string identifier | string |
| `inputName.$identifier` | Entity's identifier | string |
| `inputName.$title` | Entity's title | string |
| `inputName.$team` | Entity's owning team(s) | array |
| `inputName.propertyName` | A blueprint property of the entity | actual blueprint type |
Deep traversal (`input.relation.nested`) isn't supported, add a mirror or
calculation property on the blueprint to flatten the value you need instead.
### Operators
| Operator | Description |
|---|---|
| `=`, `!=` | Equals / not equals |
| `>`, `<`, `>=`, `<=` | Numeric/string comparison |
| `in`, `notIn` | Scalar property is/isn't in an array value |
| `contains`, `notContains` | Array property does/doesn't contain a scalar value |
| `containsAny` | Array property shares at least one element with another array value |
| `empty`, `notEmpty` | Value is/isn't empty |
**Array-typed properties** (`userTeams.*`, `$team`) reject `in`/`notIn` as
the left-hand property. Use `contains`/`notContains` against a static value,
or `containsAny` against another array (see below).
## Common patterns
Restrict to a team, dynamically (requires `$identifier` on the `_team`
blueprint):
```json
{ "policy": { "combinator": "and", "rules": [{ "property": { "context": "userTeams", "property": "$identifier" }, "operator": "contains", "value": "platform-team" } ] } }
```
Or just use the static shortcut instead, it's simpler for this case:
```json
{ "teams": ["platform-team"] }
```
Restrict to members of the team that owns the selected entity, using
`containsAny` to intersect two arrays (the entity's teams and the user's
teams):
```json
{
"policy": {
"combinator": "and",
"rules": [
{
"property": { "context": "form", "property": "service.$team" },
"operator": "containsAny",
"value": { "context": "userTeams", "property": "$identifier" }
}
]
}
}
```
Restrict to a form input value:
```json
{ "policy": { "combinator": "and", "rules": [{ "property": { "context": "form", "property": "environment" }, "operator": "=", "value": "production" } ] } }
```
Restrict to a user property, with several allowed values:
```json
{ "policy": { "combinator": "and", "rules": [{ "property": { "context": "user", "property": "department" }, "operator": "in", "value": ["engineering", "platform", "sre"] } ] } }
```
Restrict to the manager of the entity's owning team, comparing a mirror
property against the executing user's identity (requires a mirror property
on the entity's blueprint following `entity -> _team -> manager (_user) ->
$identifier`):
```json
{
"policy": {
"combinator": "and",
"rules": [
{
"property": { "context": "form", "property": "service.team_manager_id" },
"operator": "=",
"value": { "context": "user", "property": "$identifier" }
}
]
}
}
```
## Validation vs. permissions
Permissions gate *who* can start a run. To block a run based on context lake
state (for example, "don't let this run if an entity with this name already
exists"), that's a runtime validation, not a permission. Add a `CONDITION`
node as the first step and route the failing branch to a dead end (or a
node that reports back why it stopped) instead of trying to encode it in
`policy`.
SKILL.md
---
name: port-workflows
description: "Build Port workflows: node-based automations made of triggers (self-service forms, context lake events), action nodes (webhook, upsert entity, GitHub/GitLab/Azure DevOps/Claude managed agents integration actions, Kafka, Cursor Agent, AI), condition and input nodes, JQ templating between nodes, and self-service trigger permissions, authored as workflow JSON against the Port API. Use when asked to create a Port workflow, add a trigger to a workflow, add an action node, wire up a self-service workflow, add branching with a condition node, add an approval step to a workflow, build an event-driven automation in Port, or write workflow JSON. Port workflows are Port's own nodes-and-edges automation graph, not a CI/CD pipeline file like GitHub Actions."
license: MIT
compatibility: "Claude Code, Cursor, Codex CLI, GitHub Copilot"
metadata:
version: "1.0.0"
author: port-labs
repository: https://github.com/port-labs/port-skills
tags: port,workflows,automation,reference
summary: Build Port workflows with triggers, action nodes, and conditions
---
# Port workflows
A workflow is Port's visual, node-based automation layer. Nodes (triggers,
actions, conditions, inputs) connect into a graph; running the workflow walks
that graph, passing each node's output to the next as JQ-templated data.
## Use this skill when
Use this skill to author or edit workflow JSON: adding a self-service or
event trigger, adding an action node (webhook, upsert entity, an integration
action, Kafka, Cursor Agent, or AI), branching with a condition node, gating
on human approval with an input node, or wiring the connections and JQ
templates between them. This skill is reference-only: the JSON it produces is
valid without a live Port account, though applying it needs API credentials
(see [Prerequisites](#prerequisites)).
Out of scope: Port's older, single-step Actions & Automations model (not
covered by a skill in this repo yet), blueprint schema design (`port-blueprints`), and
CI/CD pipeline files such as `.github/workflows/*.yml`, GitHub Actions is a
separate product that a workflow can *trigger* (via `INTEGRATION_ACTION`)
but does not replace.
## Prerequisites
- Go over the `port-getting-started` skill first if this is your first time
working with Port.
- To create or update workflows without MCP: a Port `CLIENT_ID` and
`CLIENT_SECRET` (**...** menu > **Credentials** in the Port app),
exchanged for a bearer token at `POST https://api.port.io/v1/auth/access_token`.
- If Port's MCP server is connected, this skill can use its workflow tools
(`list_workflows`, `get_workflow`, `upsert_workflow`) to read or apply a
workflow directly instead of you copying JSON in by hand, but the raw API
calls in [Step 6](#step-6---create-or-update-via-the-port-api) always
work as a fallback. Search `search_port_knowledge_sources` for anything
this skill doesn't cover.
- Workflows are in open beta: cross-check unfamiliar fields against
[docs.port.io/workflows](https://docs.port.io/workflows/overview) before
shipping.
## Step 1 - Sketch the graph and pick trigger type(s)
Precondition: you know what should start the automation and what it should do.
Action: decide `SELF_SERVE_TRIGGER` (a user submits a form on demand),
`EVENT_TRIGGER` (an entity is created, updated, deleted, or a timer property
expires), or both, a workflow can define multiple trigger nodes feeding the
same downstream graph. Then list the actions in order: webhook calls, entity
upserts, integration dispatches, AI steps, with any branching (condition) or
human approval (input) points.
Fallback: if the trigger is unclear, default to a self-service trigger, it is
the safer, opt-in default and easiest to test.
## Step 2 - Configure the trigger node(s)
Precondition: trigger type chosen.
Action: for `SELF_SERVE_TRIGGER`, define `userInputs.properties` (and
`required`); for `EVENT_TRIGGER`, define `event.type` and
`blueprintIdentifier`, plus an optional JQ `condition` to filter which events
fire the workflow. Full field reference, including `contexts` (bolt-menu and
create-entity surfacing) and `variant`, is in
[references/node-types.md](references/node-types.md#trigger-nodes).
Fallback: never add `title`, `icon`, or `description` to a trigger node,
those fields are ignored there and belong on action nodes instead.
## Step 3 - Add action and flow nodes
Precondition: trigger is defined.
Action: add one node per step. See
[references/node-types.md](references/node-types.md) for the full field
reference and JSON shape of every node type: `WEBHOOK`, `UPSERT_ENTITY`,
`INTEGRATION_ACTION` (GitHub, Claude managed agents, and others), `KAFKA`,
`CURSOR_AGENT`, `AI_AGENT`, `AI`, `CONDITION`, `INPUT`. Reference upstream
data with JQ templates, see
[references/data-flow.md](references/data-flow.md).
Fallback: if a step just needs to call an HTTP API, prefer `WEBHOOK` over
building a custom integration, and prefer `UPSERT_ENTITY` over a raw
`WEBHOOK` call to Port's entity API, it is simpler and less error-prone.
## Step 4 - Wire connections
Precondition: nodes exist.
Action: add a `connections` entry (`sourceIdentifier`, `targetIdentifier`)
per edge. `CONDITION` and `INPUT` nodes need `sourceOutletIdentifier` (or
`fallback: true`) on every outgoing connection, matching one of the node's
`outlets`. Trigger nodes can only be sources, never targets. Each node (and
each outlet) can have at most one outgoing connection, fan-out is not
supported, chain nodes sequentially instead.
Fallback: got a fan-out requirement (one node feeding two independent next
steps)? Split it into two workflows, or serialize the two branches behind a
single downstream node instead of trying to fan out directly.
## Step 5 - Set self-service permissions
Precondition: workflow has a `SELF_SERVE_TRIGGER` node.
Action: set `permissions` on the trigger node's `config`. Omitted or `{}`
means Admin only. See
[references/permissions.md](references/permissions.md) for static
(`roles`/`users`/`teams`) and dynamic `policy` rules (based on user, team, or
form-input properties).
Fallback: event triggers have no `permissions` field, event-driven workflows
run under the organization's automation identity, not a specific user.
## Step 6 - Create or update via the Port API
Precondition: you have a bearer token and a complete workflow JSON body
(`identifier`, `nodes`, `connections`, at minimum).
Action: use the endpoints below (`workflow_identifier` is the workflow's
`identifier`).
| Operation | Method | Path |
|---|---|---|
| Create | `POST` | `/v1/workflows` |
| List | `GET` | `/v1/workflows` |
| Get | `GET` | `/v1/workflows/{workflow_identifier}` |
| Update (creates a new version) | `PUT` | `/v1/workflows/{workflow_identifier}` |
| Delete | `DELETE` | `/v1/workflows/{workflow_identifier}` |
| Trigger a run | `POST` | `/v1/workflows/{workflow_identifier}/runs` |
```bash
curl -L -X POST 'https://api.port.io/v1/workflows' \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H 'Content-Type: application/json' \
--data @workflow.json
```
Fallback: a `409` on create means the `identifier` is taken, `PUT` an update
instead. A `422` usually means a `connections` entry references a node
identifier that doesn't exist, or a `CONDITION`/`INPUT` connection is missing
`sourceOutletIdentifier`, check [Common pitfalls](#common-pitfalls) below.
## Complete examples
[assets/self-service-deploy-workflow.json](assets/self-service-deploy-workflow.json) -
self-service trigger, environment branching with a `CONDITION` node, and a
Slack notification.
[assets/event-driven-ai-incident-response.json](assets/event-driven-ai-incident-response.json) -
event trigger with a JQ filter, an `AI` node with `outputSchema` for
structured output, and an entity upsert.
[assets/approval-workflow.json](assets/approval-workflow.json) - self-service
trigger gated by an `INPUT` approval step before a production deploy.
## Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| `.outputs.my-node.field` silently returns nothing | Hyphens in a node identifier break JQ dot notation (`-` reads as subtraction) | Use bracket notation `.outputs["my-node"].field`, or keep node identifiers snake_case so dot notation works |
| Adding `variables` makes existing output references break | `variables` **replaces** a node's entire default output, `response` and friends disappear | Re-declare anything downstream still needs inside `variables`, e.g. `"response": "{{ .result.response }}"` |
| `CONDITION`/`INPUT` connection has no effect, or workflow rejects it | Missing `sourceOutletIdentifier` (or `fallback: true`) on the connection | Every outgoing connection from a `CONDITION` or `INPUT` node must set `sourceOutletIdentifier` matching one of the node's `outlets`, or `fallback: true` |
| `UPSERT_ENTITY` (or another structured node) after an `AI` node gets nothing | The AI node emitted free-form text, not JSON | Set `outputSchema` (JSON Schema, `type: "object"`) on the AI node, then parse with `{{ .outputs.node_id.response \| fromjson \| .field }}` |
| Event trigger fires but `.outputs.trigger.diff.before` is unexpectedly `null` | `ENTITY_CREATED` has no `before`, `ENTITY_DELETED` has no `after` | Branch on `.outputs.trigger.action` (`CREATE`/`UPDATE`/`DELETE`) before reading `diff.before`/`diff.after` |
| Workflow silently does nothing when a node fails mid-graph | Default `onFailure` is `"terminate"`, the whole run stops | Set `"onFailure": "continue"` on `WEBHOOK`, `UPSERT_ENTITY`, `KAFKA`, or `INTEGRATION_ACTION` nodes that shouldn't block the rest of the run |
| GitHub Actions workflow never fires | Used `WEBHOOK` to call the GitHub API by hand, or used the wrong field name for status reporting | Use `INTEGRATION_ACTION` with `integrationProvider: "github-ocean"`, and `reportWorkflowStatus` (not `reportStatus`) to get status back in Port |
## Quick reference
- Node types: `SELF_SERVE_TRIGGER`, `EVENT_TRIGGER` (triggers); `WEBHOOK`,
`UPSERT_ENTITY`, `INTEGRATION_ACTION`, `KAFKA`, `CURSOR_AGENT`, `AI_AGENT`,
`AI` (actions); `CONDITION`, `INPUT` (flow control). Full reference:
[references/node-types.md](references/node-types.md).
- Data access: `{{ .outputs["node_id"].field }}` (previous node output),
`{{ .outputs.trigger.field }}` (always aliases the trigger that fired),
`{{ .secrets["name"] }}`, `{{ .workflowRun.trigger.by.email }}`. Full
reference: [references/data-flow.md](references/data-flow.md).
- Permissions: `{}`/omitted = Admin only; `roles`/`users`/`teams` = static OR
match; `policy` = dynamic rules over `user`, `userTeams`, or `form`
context. Full reference: [references/permissions.md](references/permissions.md).
- Auth: `POST /v1/auth/access_token` with `clientId`/`clientSecret` for a
bearer token; no `Authorization` header needed when a workflow node itself
calls `https://api.port.io`.
- Workflow top-level fields: `identifier` (required, ≤60 chars,
`^[A-Za-z0-9@_:-]+$`), `title`, `icon`, `description`, `category` (groups
it in the UI), `allowAnyoneToViewRuns` (default `true`), `nodes`,
`connections`.